mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-17 20:57:11 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b26596ce |
No files matched your search
@@ -1,11 +1,2 @@
|
||||
tools/linuxfs/** text=auto eol=lf
|
||||
tools/mangoapp/** text=auto eol=lf
|
||||
tools/comet/LICENSE whitespace=-blank-at-eof
|
||||
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
|
||||
|
||||
# These helpers are packaged verbatim and executed inside the Linux runtime.
|
||||
tools/linuxfs/overlay/** text eol=lf
|
||||
tools/linuxfs/desktop/** text eol=lf
|
||||
tools/mangoapp/mangoapp text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.json text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.txt text eol=lf
|
||||
+15
-81
@@ -193,7 +193,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
|
||||
D=app/src/main/assets/linuxfs
|
||||
mkdir -p "$D"
|
||||
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
|
||||
@@ -396,30 +396,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
|
||||
- name: Check Quest tracking IPC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
|
||||
mkdir -p build/vr/include
|
||||
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
|
||||
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
|
||||
/tmp/test-xr-tracking
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
|
||||
/tmp/test-xr-touch
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
|
||||
/tmp/test-xr-vsync
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
|
||||
/tmp/test-xr-frames
|
||||
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
|
||||
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
|
||||
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
|
||||
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
|
||||
-ldl -o /tmp/test-presenter-shutdown
|
||||
timeout 10 /tmp/test-presenter-shutdown
|
||||
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
id: rust
|
||||
run: |
|
||||
@@ -453,7 +429,7 @@ jobs:
|
||||
path: |
|
||||
app/.cxx
|
||||
app/build/intermediates/cxx
|
||||
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**', 'tools/vr/src/xr_*.h') }}
|
||||
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
|
||||
restore-keys: |
|
||||
cxx-${{ env.NDK_VERSION }}-
|
||||
|
||||
@@ -506,24 +482,7 @@ jobs:
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
- name: Unit tests
|
||||
id: unit-tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p app/build
|
||||
./gradlew testReleaseUnitTest --console=plain --info --stacktrace -Drobolectric.logging=stderr -PndkVersion="$NDK_VERSION" 2>&1 | tee app/build/unit-tests.log
|
||||
|
||||
- name: Upload unit test diagnostics
|
||||
id: unit-test-diagnostics
|
||||
if: always() && steps.unit-tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-test-results
|
||||
path: |
|
||||
app/build/unit-tests.log
|
||||
app/build/test-results/testReleaseUnitTest/
|
||||
app/build/reports/tests/testReleaseUnitTest/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Sign with the testkey (v1 + v2 + v3)
|
||||
run: |
|
||||
@@ -553,15 +512,6 @@ jobs:
|
||||
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|
||||
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
id: apk-upload
|
||||
with:
|
||||
name: droiddeck-apk
|
||||
path: app/build/outputs/apk/release/*.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
- name: Report on the pull request
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
@@ -570,9 +520,6 @@ jobs:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
BUILD_OUTCOME: ${{ steps.build.outcome }}
|
||||
UNIT_TEST_OUTCOME: ${{ steps.unit-tests.outcome }}
|
||||
TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }}
|
||||
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
|
||||
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
@@ -583,8 +530,8 @@ jobs:
|
||||
echo
|
||||
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
|
||||
echo
|
||||
case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
|
||||
success:success:success)
|
||||
case "$BUILD_OUTCOME" in
|
||||
success)
|
||||
echo "### Build passed"
|
||||
echo
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
|
||||
@@ -597,28 +544,7 @@ jobs:
|
||||
echo
|
||||
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
|
||||
;;
|
||||
success:success:*)
|
||||
echo "### APK publication incomplete"
|
||||
echo
|
||||
echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
|
||||
;;
|
||||
success:failure:*)
|
||||
echo "### ERROR: Unit tests failed"
|
||||
echo
|
||||
echo "The APK compiled, but unit tests failed. No installable artifact was published."
|
||||
echo
|
||||
if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
|
||||
echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
|
||||
else
|
||||
echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
|
||||
fi
|
||||
;;
|
||||
success:*)
|
||||
echo "### Build incomplete"
|
||||
echo
|
||||
echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
|
||||
;;
|
||||
failure:*)
|
||||
failure)
|
||||
echo "### ERROR: Build failed"
|
||||
echo
|
||||
echo "The compiler's errors ([full log]($RUN_URL)):"
|
||||
@@ -636,7 +562,7 @@ jobs:
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
;;
|
||||
skipped:*|cancelled:*)
|
||||
skipped|cancelled)
|
||||
echo "### Not built"
|
||||
echo
|
||||
echo "The build did not run (see above, or [the run]($RUN_URL))."
|
||||
@@ -652,6 +578,14 @@ jobs:
|
||||
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: droiddeck-apk
|
||||
path: app/build/outputs/apk/release/*.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
sign:
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Rules
|
||||
|
||||
- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
|
||||
@@ -15,9 +15,9 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
|
||||
|
||||
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
## Community
|
||||
|
||||
@@ -29,12 +29,6 @@ Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust throu
|
||||
|
||||
After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too.
|
||||
|
||||
For the prepared Windows checkout, run `./tools/run_windows.ps1` (optionally with `-Serial <adb-serial>`), or select **DroidDeck XR** in Android Studio and run the **app** module on the connected phone. Machine-specific options live in the ignored `local.properties`: `droiddeck.python` selects a Python executable, `droiddeck.skipRust=true` omits the optional store download engines, and `droiddeck.debugApplicationIdSuffix=.xr` installs **DroidDeck XR Dev** alongside the signed release. The development app has its own runtime and Steam login. The local Java selection is in `.gradle/config.properties`.
|
||||
|
||||
`python tools/prepare_windows_runtime.py` restores this checkout's audited runtime inputs from the pinned CI APK, three locally rebuilt preload libraries, and the checksum-pinned Gamescope archive in `build/windows-setup/`. Start Docker Desktop's Linux engine first: preparation now rebuilds DroidDeck's ARM64 VR driver and Vulkan layer from current source and runs their native regression tests. The compiler image is built automatically on first use. Vulkan headers come from the NDK beside `--llvm-strip`, or from an explicit `--vk-include <NDK-sysroot-include>` directory.
|
||||
|
||||
To reuse that build, pass `--rebuilt-vr build/windows-setup/vr`. Preparation verifies its `vr-build.json` against the current Git revision, source files, Vulkan headers, output checksums, and ARM64 architecture; missing or stale builds fail instead of falling back to the donor's VR libraries. The script checks the actual donor CI merge tree, still rejects unrelated unaudited runtime changes, and records component provenance in `app/build/windows-runtime-provenance.json`. Preserve these ignored preparation files when rebuilding locally; `clean` removes the prepared assets and their verification manifest.
|
||||
|
||||
Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers.
|
||||
|
||||
## Limits
|
||||
@@ -43,10 +37,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
|
||||
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
|
||||
|
||||
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
|
||||
|
||||
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
+4
-52
@@ -4,25 +4,6 @@ plugins {
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
// Machine-specific tooling and optional CI-prepared inputs stay out of version control.
|
||||
def localBuildOptions = new Properties()
|
||||
def localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties')
|
||||
if (localBuildOptionsFile.asFile.exists()) {
|
||||
localBuildOptions.load(new StringReader(providers.fileContents(localBuildOptionsFile).asText.get()))
|
||||
}
|
||||
def pythonExecutable = project.findProperty('pythonExecutable')
|
||||
?: localBuildOptions.getProperty('droiddeck.python', 'python3')
|
||||
def vitureSdkDir = project.findProperty('vitureSdkDir') ?: localBuildOptions.getProperty('droiddeck.vitureSdk', '')
|
||||
if (vitureSdkDir) {
|
||||
['android/arm64-v8a/libglasses.so', 'android/arm64-v8a/libcarina_vio.so',
|
||||
'android/arm64-v8a/libcloud_protocol.so', 'include/viture_glasses_provider.h',
|
||||
'android/LICENSE', 'android/NOTICE'].each { required ->
|
||||
if (!new File(vitureSdkDir, required).isFile()) {
|
||||
throw new GradleException("Incomplete VITURE Android SDK: missing ${required}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def gitValue = { List<String> arguments ->
|
||||
try {
|
||||
def output = providers.exec {
|
||||
@@ -119,7 +100,6 @@ android {
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments '-DANDROID_STL=c++_shared'
|
||||
arguments "-DDROIDDECK_VITURE_SDK_DIR=${vitureSdkDir.toString().replace('\\', '/')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,11 +128,6 @@ android {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
signingConfig signingConfigs.testkey
|
||||
def localSuffix = localBuildOptions.getProperty('droiddeck.debugApplicationIdSuffix')
|
||||
if (localSuffix) {
|
||||
applicationIdSuffix localSuffix
|
||||
resValue 'string', 'app_name', 'DroidDeck XR Dev'
|
||||
}
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
@@ -175,7 +150,6 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
prefab true
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
@@ -183,10 +157,6 @@ android {
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
unitTests.includeAndroidResources = true
|
||||
unitTests.all {
|
||||
def logTarget = providers.systemProperty('robolectric.logging').orNull
|
||||
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -198,16 +168,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor binaries remain in the user's SDK directory; never vendor them into Git.
|
||||
if (vitureSdkDir) {
|
||||
android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android'))
|
||||
def stageVitureNotices = tasks.register('stageVitureNotices', Copy) {
|
||||
from(new File(vitureSdkDir, 'android')) { include 'LICENSE', 'NOTICE' }
|
||||
into(layout.buildDirectory.dir('vitureAssets/licenses/viture'))
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageVitureNotices) }
|
||||
}
|
||||
|
||||
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
|
||||
from(rootProject.file('tools/linuxfs/overlay')) {
|
||||
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**'
|
||||
@@ -224,13 +184,12 @@ def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
|
||||
if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets'))
|
||||
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
|
||||
|
||||
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
|
||||
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
|
||||
workingDir rootProject.projectDir
|
||||
commandLine pythonExecutable, 'tools/release/runtime_inputs.py', 'verify'
|
||||
commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify'
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
|
||||
@@ -238,8 +197,7 @@ tasks.configureEach { task ->
|
||||
|
||||
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
|
||||
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
|
||||
def skipRust = (project.findProperty('skipRust')
|
||||
?: localBuildOptions.getProperty('droiddeck.skipRust', 'false')).toString().toBoolean()
|
||||
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
|
||||
def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
|
||||
onlyIf { !skipRust }
|
||||
@@ -267,8 +225,7 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
|
||||
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
@@ -283,14 +240,9 @@ dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'com.flyfishxu:kadb-android:1.2.1'
|
||||
implementation 'com.termux.termux-app:terminal-view:0.118.0'
|
||||
implementation 'androidx.camera:camera-camera2:1.4.2'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.4.2'
|
||||
implementation('com.google.mediapipe:tasks-vision:0.10.21') {
|
||||
exclude group: 'com.google.auto.value', module: 'auto-value'
|
||||
}
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.7-4@jar'
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@@ -20,19 +18,13 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -44,35 +36,20 @@
|
||||
android:isGame="true">
|
||||
|
||||
<meta-data android:name="android.game_mode_config" android:resource="@xml/game_mode_config" />
|
||||
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s" />
|
||||
<activity android:name=".QuestXrActivity" android:exported="true" android:launchMode="singleTask"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:resizeableActivity="false" android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode"
|
||||
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
|
||||
<meta-data android:name="android.app.lib_name" android:value="droiddeckxr" />
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.XR" />
|
||||
<category android:name="com.oculus.intent.category.VR" />
|
||||
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
|
||||
android:configChanges="keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
<data android:scheme="droiddeck" android:host="game" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
@@ -82,7 +59,6 @@
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -115,21 +91,18 @@
|
||||
android:name=".files.FileManagerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".files.FilePickerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
@@ -151,16 +124,6 @@
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
|
||||
|
||||
<activity
|
||||
android:name=".gpu.SteamVrRestartActivity"
|
||||
android:exported="false"
|
||||
android:process=":vr_recovery"
|
||||
android:taskAffinity="${applicationId}.vr_recovery"
|
||||
android:excludeFromRecents="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<service
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
@@ -200,12 +163,5 @@
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
<service
|
||||
android:name="com.google.android.datatransport.runtime.backends.TransportBackendDiscovery"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="backend:com.google.android.datatransport.cct.CctBackendFactory"
|
||||
tools:node="remove" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -30,7 +30,6 @@ string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
|
||||
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
|
||||
|
||||
add_subdirectory(waylandcomp)
|
||||
add_subdirectory(questxr)
|
||||
add_library(sdnative SHARED sdnative/sdnative.c)
|
||||
target_link_libraries(sdnative log)
|
||||
|
||||
@@ -38,12 +37,3 @@ add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
|
||||
target_link_libraries(deviceinfo dl)
|
||||
|
||||
add_library(termux SHARED termux/termux.c)
|
||||
|
||||
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
|
||||
if(DROIDDECK_VITURE_SDK_DIR)
|
||||
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
|
||||
target_compile_features(droiddeck_viture PRIVATE cxx_std_17)
|
||||
target_include_directories(droiddeck_viture PRIVATE "${DROIDDECK_VITURE_SDK_DIR}/include")
|
||||
target_link_directories(droiddeck_viture PRIVATE "${DROIDDECK_VITURE_SDK_DIR}/android/${ANDROID_ABI}")
|
||||
target_link_libraries(droiddeck_viture PRIVATE glasses log)
|
||||
endif()
|
||||
@@ -371,17 +371,6 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
|
||||
syscall(SYS_close, sock);
|
||||
}
|
||||
|
||||
// Must match RumbleComponent.HOLD_MS: play until the slot's next effect or stop.
|
||||
static constexpr uint16_t RUMBLE_HOLD_MS = 0xffff;
|
||||
|
||||
__attribute__((visibility("hidden"))) static void send_ff_effect(const struct ff_effect &effect, uint16_t slot) {
|
||||
uint16_t duration = effect.replay.length ? effect.replay.length : RUMBLE_HOLD_MS;
|
||||
if (effect.type == FF_RUMBLE)
|
||||
send_vibration(effect.u.rumble.strong_magnitude, effect.u.rumble.weak_magnitude, duration, slot);
|
||||
else if (effect.type == FF_PERIODIC)
|
||||
send_vibration(effect.u.periodic.magnitude, effect.u.periodic.magnitude, duration, slot);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void
|
||||
check_ff_event(const struct input_event *ev, uint16_t slot) {
|
||||
if (ev->type != EV_FF)
|
||||
@@ -392,7 +381,15 @@ check_ff_event(const struct input_event *ev, uint16_t slot) {
|
||||
auto it = ff_effects().find(id);
|
||||
if (it == ff_effects().end())
|
||||
return;
|
||||
send_ff_effect(it->second, slot);
|
||||
|
||||
uint16_t duration = it->second.replay.length;
|
||||
if (it->second.type == FF_RUMBLE) {
|
||||
send_vibration(it->second.u.rumble.strong_magnitude,
|
||||
it->second.u.rumble.weak_magnitude, duration, slot);
|
||||
} else if (it->second.type == FF_PERIODIC) {
|
||||
send_vibration(it->second.u.periodic.magnitude,
|
||||
it->second.u.periodic.magnitude, duration, slot);
|
||||
}
|
||||
} else {
|
||||
send_vibration(0, 0, 0, slot);
|
||||
}
|
||||
@@ -1143,12 +1140,8 @@ struct DeckImu {
|
||||
uint16_t pressure[2];
|
||||
uint32_t controls;
|
||||
uint32_t pad_type;
|
||||
uint32_t latency;
|
||||
};
|
||||
static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter");
|
||||
static_assert(offsetof(DeckImu, latency) == 48, "DeckImu::latency matches FakeInputWriter");
|
||||
static constexpr uint32_t LATENCY_CONTROLLER = 1, LATENCY_TOUCH = 2;
|
||||
static constexpr int LOW_LATENCY_REPORT_INTERVAL_US = 1000;
|
||||
static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4,
|
||||
DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16,
|
||||
DECK_EXTRA_RPAD_TOUCH = 32, DECK_EXTRA_LPAD_CLICK = 64,
|
||||
@@ -1310,27 +1303,6 @@ __attribute__((visibility("hidden"))) static uint32_t live_pad_type() {
|
||||
return value & PAD_TYPE_SET ? value & 0xff : session_pad_type();
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool low_latency(uint32_t which) {
|
||||
const uint8_t *blocks = ring_blocks();
|
||||
const DeckImu *imu = blocks ? reinterpret_cast<const DeckImu *>(blocks + FAKE_INPUT_RING_SIZE) : nullptr;
|
||||
return imu && (__atomic_load_n(&imu->latency, __ATOMIC_ACQUIRE) & which);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int controller_report_interval_us(int normal_us) {
|
||||
return low_latency(LATENCY_CONTROLLER) ? LOW_LATENCY_REPORT_INTERVAL_US : normal_us;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void sleep_us(int us) {
|
||||
struct timespec interval = {0, us * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static uint64_t monotonic_us() {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return static_cast<uint64_t>(now.tv_sec) * 1000000ULL + static_cast<uint64_t>(now.tv_nsec) / 1000ULL;
|
||||
}
|
||||
|
||||
static bool fake_triton_enabled() {
|
||||
static int enabled = -1;
|
||||
if (enabled < 0)
|
||||
@@ -1481,7 +1453,8 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
|
||||
struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -1513,7 +1486,7 @@ __attribute__((visibility("hidden"))) static void put_sony_touch(uint8_t *at, So
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void
|
||||
build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes,
|
||||
build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t buttons, const int32_t *axes,
|
||||
const DeckExtras &extras, SonyTouch &touch) {
|
||||
memset(report, 0, SONY_REPORT_BYTES);
|
||||
report[0] = 0x01;
|
||||
@@ -1544,7 +1517,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elap
|
||||
p[6] = static_cast<uint8_t>(system | ((packet & 0x3f) << 2));
|
||||
p[7] = left_trigger;
|
||||
p[8] = right_trigger;
|
||||
put16(p + 9, static_cast<int>(elapsed_us * 3 / 16));
|
||||
put16(p + 9, static_cast<int>(packet * 750));
|
||||
for (int i = 0; i < 3; i++) {
|
||||
put16(p + 12 + i * 2, gyro[i]);
|
||||
put16(p + 18 + i * 2, accel[i]);
|
||||
@@ -1566,7 +1539,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elap
|
||||
put16(p + 15 + i * 2, gyro[i]);
|
||||
put16(p + 21 + i * 2, accel[i]);
|
||||
}
|
||||
put32(p + 27, static_cast<uint32_t>(elapsed_us * 3));
|
||||
put32(p + 27, packet * 12000);
|
||||
put_sony_touch(p + 32, touch, 0, left, extras.pads[0], extras.pads[1], SONY_TOUCH_HEIGHT_DUALSENSE);
|
||||
put_sony_touch(p + 36, touch, 1, right, extras.pads[2], extras.pads[3], SONY_TOUCH_HEIGHT_DUALSENSE);
|
||||
p[52] = 0x2a;
|
||||
@@ -1585,7 +1558,6 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
|
||||
int32_t axes[8] = {};
|
||||
DeckExtras extras;
|
||||
SonyTouch touch;
|
||||
const uint64_t start_us = monotonic_us();
|
||||
while (live_pad_type() == type) {
|
||||
if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
|
||||
buttons = snap.buttons;
|
||||
@@ -1593,11 +1565,12 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
|
||||
}
|
||||
read_deck_extras(*self, extras);
|
||||
uint8_t report[SONY_REPORT_BYTES];
|
||||
build_sony_report(report, self->sony, ++packet, monotonic_us() - start_us, buttons, axes, extras, touch);
|
||||
build_sony_report(report, self->sony, ++packet, buttons, axes, extras, touch);
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
|
||||
struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -1711,7 +1684,8 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
}
|
||||
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
|
||||
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -1735,7 +1709,7 @@ static constexpr int16_t TRITON_WIRED_PRODUCT = 0x1302;
|
||||
static constexpr int32_t TRITON_STICK_TOUCH = 2000;
|
||||
|
||||
__attribute__((visibility("hidden"))) static void
|
||||
build_triton_report(uint8_t *report, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes,
|
||||
build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes,
|
||||
const DeckExtras &extras) {
|
||||
memset(report, 0, TRITON_STATE_BYTES);
|
||||
report[0] = static_cast<uint8_t>(packet);
|
||||
@@ -1778,7 +1752,7 @@ build_triton_report(uint8_t *report, uint32_t packet, uint64_t elapsed_us, uint3
|
||||
put16(report + 23, extras.pads[2]);
|
||||
put16(report + 25, extras.pads[3]);
|
||||
put16(report + 27, extras.pressure[1]);
|
||||
put32(report + 29, static_cast<uint32_t>(elapsed_us));
|
||||
put32(report + 29, packet * TRITON_REPORT_INTERVAL_US);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
put16(report + 33 + i * 2, extras.accel[i]);
|
||||
put16(report + 39 + i * 2, extras.gyro[i]);
|
||||
@@ -1794,7 +1768,6 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
|
||||
uint32_t buttons = 0;
|
||||
int32_t axes[8] = {};
|
||||
DeckExtras extras;
|
||||
const uint64_t start_us = monotonic_us();
|
||||
while (live_pad_type() == PAD_TYPE_STEAM) {
|
||||
if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
|
||||
buttons = snap.buttons;
|
||||
@@ -1803,11 +1776,12 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
|
||||
read_deck_extras(*self, extras);
|
||||
uint8_t report[TRITON_REPORT_ID_BYTES + TRITON_STATE_BYTES];
|
||||
report[0] = TRITON_STATE_WIRED;
|
||||
build_triton_report(report + 1, ++packet, monotonic_us() - start_us, buttons, axes, extras);
|
||||
build_triton_report(report + 1, ++packet, buttons, axes, extras);
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
|
||||
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -2045,7 +2019,8 @@ __attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
sleep_us(low_latency(LATENCY_TOUCH) ? LOW_LATENCY_REPORT_INTERVAL_US : TOUCH_REPORT_INTERVAL_US);
|
||||
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
|
||||
@@ -3472,7 +3447,16 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
if (effect->id == -1)
|
||||
effect->id = next_ff_id++;
|
||||
ff_effects()[effect->id] = *effect;
|
||||
send_ff_effect(*effect, static_cast<uint16_t>(event_number));
|
||||
|
||||
uint16_t duration = effect->replay.length;
|
||||
uint16_t slot = static_cast<uint16_t>(event_number);
|
||||
if (effect->type == FF_RUMBLE) {
|
||||
send_vibration(effect->u.rumble.strong_magnitude,
|
||||
effect->u.rumble.weak_magnitude, duration, slot);
|
||||
} else if (effect->type == FF_PERIODIC) {
|
||||
send_vibration(effect->u.periodic.magnitude, effect->u.periodic.magnitude,
|
||||
duration, slot);
|
||||
}
|
||||
return 0;
|
||||
} else if (type == 0x45 && number == 0x81) {
|
||||
int id = (intptr_t)argp;
|
||||
@@ -3561,7 +3545,6 @@ EXPORT int close(int fd) {
|
||||
}
|
||||
|
||||
static constexpr int kMaxRingWaitMs = 2;
|
||||
static constexpr int kLowLatencyRingWaitUs = 250;
|
||||
|
||||
EXPORT ssize_t read(int fd, void *buf, size_t count) {
|
||||
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
|
||||
@@ -3650,8 +3633,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
|
||||
int result = nanosleep(&sleep_time, nullptr);
|
||||
guard.lock();
|
||||
if (result < 0) return -1;
|
||||
if (low_latency(LATENCY_CONTROLLER)) backoff_ns = kLowLatencyRingWaitUs * 1000;
|
||||
else if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
|
||||
if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3833,7 +3815,7 @@ static int poll_fake(struct pollfd *fds, nfds_t nfds, int timeout,
|
||||
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
|
||||
return 0;
|
||||
|
||||
if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER))
|
||||
if (backoff_ms < kMaxRingWaitMs)
|
||||
backoff_ms *= 2;
|
||||
}
|
||||
}
|
||||
@@ -4006,7 +3988,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
|
||||
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
|
||||
return 0;
|
||||
|
||||
if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER))
|
||||
if (backoff_ms < kMaxRingWaitMs)
|
||||
backoff_ms *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(droiddeckxr C CXX)
|
||||
find_package(OpenXR REQUIRED CONFIG)
|
||||
add_library(droiddeckxr_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c)
|
||||
target_include_directories(droiddeckxr_glue PUBLIC ${ANDROID_NDK}/sources/android/native_app_glue)
|
||||
add_library(droiddeckxr SHARED quest_xr.cpp)
|
||||
target_compile_features(droiddeckxr PRIVATE cxx_std_17)
|
||||
target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES XR_USE_TIMESPEC)
|
||||
target_compile_options(droiddeckxr PRIVATE -Wall -Wextra -Wno-missing-field-initializers -Werror=return-type)
|
||||
target_link_options(droiddeckxr PRIVATE -Wl,-z,max-page-size=16384 -uANativeActivity_onCreate)
|
||||
target_link_libraries(droiddeckxr PRIVATE droiddeckxr_glue OpenXR::openxr_loader EGL GLESv3 android log)
|
||||
|
||||
target_include_directories(droiddeckxr PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/vr/src)
|
||||
@@ -1,201 +0,0 @@
|
||||
#pragma once
|
||||
#include <EGL/eglext.h>
|
||||
#include <GLES3/gl31.h>
|
||||
#include <GLES2/gl2ext.h>
|
||||
#include <android/hardware_buffer.h>
|
||||
#include "xr_gpu_channel.h"
|
||||
|
||||
class QuestGpuFrames {
|
||||
public:
|
||||
bool Start(const std::string &filename, EGLDisplay display) {
|
||||
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
|
||||
const char *eglExtensions = eglQueryString(display, EGL_EXTENSIONS);
|
||||
if (!extensions || !eglExtensions || !strstr(extensions, "GL_EXT_external_buffer")
|
||||
|| !strstr(eglExtensions, "EGL_ANDROID_get_native_client_buffer")) return false;
|
||||
storage_ = reinterpret_cast<PFNGLBUFFERSTORAGEEXTERNALEXTPROC>(eglGetProcAddress("glBufferStorageExternalEXT"));
|
||||
client_ = reinterpret_cast<PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC>(eglGetProcAddress("eglGetNativeClientBufferANDROID"));
|
||||
if (strstr(extensions, "GL_OES_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewOES"));
|
||||
else if (strstr(extensions, "GL_EXT_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewEXT"));
|
||||
if (!storage_ || !client_ || !view_ || filename.size() >= sizeof(sockaddr_un::sun_path)) return false;
|
||||
const char *source = "#version 310 es\nlayout(local_size_x=8,local_size_y=8)in;"
|
||||
"layout(std430,binding=0)readonly buffer Source{uint pixels[];};"
|
||||
"layout(rgba8,binding=0)writeonly uniform highp image2D target;uniform bool bgra;"
|
||||
"void main(){ivec2 p=ivec2(gl_GlobalInvocationID.xy),size=imageSize(target);"
|
||||
"if(any(greaterThanEqual(p,size)))return;vec4 value=unpackUnorm4x8(pixels[p.y*size.x+p.x]);"
|
||||
"imageStore(target,p,bgra?value.bgra:value);}";
|
||||
GLuint shader = glCreateShader(GL_COMPUTE_SHADER); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader);
|
||||
program_ = glCreateProgram(); glAttachShader(program_, shader); glLinkProgram(program_); glDeleteShader(shader);
|
||||
GLint linked{}; glGetProgramiv(program_, GL_LINK_STATUS, &linked);
|
||||
if (!linked) { Stop(); return false; }
|
||||
lock_ = open((filename + ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
struct stat status{};
|
||||
if (lock_ < 0 || fstat(lock_, &status) || !S_ISREG(status.st_mode) || status.st_uid != getuid()
|
||||
|| flock(lock_, LOCK_EX | LOCK_NB)) { Stop(); return false; }
|
||||
if (lstat(filename.c_str(), &status) == 0) {
|
||||
if (!S_ISSOCK(status.st_mode) || status.st_uid != getuid() || unlink(filename.c_str())) { Stop(); return false; }
|
||||
} else if (errno != ENOENT) { Stop(); return false; }
|
||||
listener_ = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
|
||||
sockaddr_un address{}; address.sun_family = AF_UNIX; strcpy(address.sun_path, filename.c_str());
|
||||
if (listener_ < 0 || bind(listener_, reinterpret_cast<sockaddr *>(&address), sizeof(address))) { Stop(); return false; }
|
||||
filename_ = filename;
|
||||
if (chmod(filename.c_str(), 0600) || listen(listener_, 2)) { Stop(); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Take(DroidDeckXrFrameHeader &header, GLuint &texture) {
|
||||
if (listener_ < 0) return false;
|
||||
if (peer_ < 0) {
|
||||
peer_ = accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC | SOCK_NONBLOCK);
|
||||
if (peer_ < 0) return false;
|
||||
if (!xr_frame_peer(peer_)) { Disconnect(); return false; }
|
||||
configured_ = false; deadline_ = xr_frame_now() + 2000000000LL;
|
||||
}
|
||||
pollfd ready{peer_, POLLIN, 0};
|
||||
if (poll(&ready, 1, 0) <= 0) {
|
||||
if (!configured_ && xr_frame_now() > deadline_) Disconnect();
|
||||
return false;
|
||||
}
|
||||
if (!(ready.revents & POLLIN)) { Disconnect(); return false; }
|
||||
if (!configured_) {
|
||||
XrGpuRequest request;
|
||||
if (!xr_gpu_receive(peer_, &request, sizeof(request)) || request.magic != XR_GPU_MAGIC
|
||||
|| request.version != XR_GPU_VERSION || !xr_gpu_dimensions(request.width, request.height)
|
||||
|| !Allocate(request.width, request.height)) { Disconnect(); return false; }
|
||||
XrGpuPoolInfo info; info.width = width_; info.height = height_;
|
||||
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) info.sizes[index] = sizes_[index];
|
||||
configured_ = xr_gpu_send(peer_, &info, sizeof(info), fds_.data(), fds_.size());
|
||||
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
|
||||
if (!configured_) Disconnect();
|
||||
return false;
|
||||
}
|
||||
XrGpuFrame frame;
|
||||
if (!xr_gpu_receive(peer_, &frame, sizeof(frame))) { Disconnect(); return false; }
|
||||
auto state = xr_gpu_frame_state(frame, width_, height_, lastFrame_, xr_frame_now());
|
||||
if (state == XrGpuFrameState::Invalid) {
|
||||
rejected_++; Disconnect(); return false;
|
||||
}
|
||||
lastFrame_ = frame.header.frame_id;
|
||||
if (state == XrGpuFrameState::Stale) {
|
||||
// No GPU read was submitted, so the producer can safely reclaim a dropped frame.
|
||||
dropped_++;
|
||||
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
|
||||
return false;
|
||||
}
|
||||
if (!Output()) { Disconnect(); return false; }
|
||||
glMemoryBarrier(GL_ALL_BARRIER_BITS);
|
||||
glUseProgram(program_); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffers_[frame.slot]);
|
||||
glUniform1i(glGetUniformLocation(program_, "bgra"), !!(frame.flags & XR_GPU_BGRA));
|
||||
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
|
||||
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
|
||||
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
|
||||
// The producer may overwrite this slot only after the native GPU has finished reading it.
|
||||
glFinish();
|
||||
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
|
||||
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
|
||||
received_++;
|
||||
header = frame.header;
|
||||
texture = header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? srgb_ : raw_;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint64_t Received() const { return received_; }
|
||||
uint64_t Rejected() const { return rejected_; }
|
||||
uint64_t Dropped() const { return dropped_; }
|
||||
void Stop() {
|
||||
Disconnect(); DropPool();
|
||||
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
|
||||
if (program_) glDeleteProgram(program_); program_ = 0;
|
||||
if (listener_ >= 0) close(listener_); listener_ = -1;
|
||||
if (!filename_.empty()) unlink(filename_.c_str()); filename_.clear();
|
||||
if (lock_ >= 0) close(lock_); lock_ = -1;
|
||||
}
|
||||
private:
|
||||
int listener_ = -1, peer_ = -1, lock_ = -1;
|
||||
std::string filename_;
|
||||
bool configured_ = false;
|
||||
int64_t deadline_{};
|
||||
uint32_t width_{}, height_{}, outputWidth_{}, outputHeight_{};
|
||||
uint64_t lastFrame_{}, received_{}, rejected_{}, dropped_{};
|
||||
std::array<AHardwareBuffer *, XR_GPU_SLOTS> hardware_{};
|
||||
std::array<GLuint, XR_GPU_SLOTS> buffers_{};
|
||||
std::array<int, XR_GPU_SLOTS> fds_{{-1, -1, -1}};
|
||||
std::array<uint64_t, XR_GPU_SLOTS> sizes_{};
|
||||
GLuint raw_{}, srgb_{}, program_{};
|
||||
PFNGLBUFFERSTORAGEEXTERNALEXTPROC storage_{};
|
||||
PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC client_{};
|
||||
PFNGLTEXTUREVIEWOESPROC view_{};
|
||||
|
||||
void Disconnect() { if (peer_ >= 0) close(peer_); peer_ = -1; configured_ = false; lastFrame_ = 0; }
|
||||
void DropPool() {
|
||||
if (buffers_[0]) glFinish();
|
||||
glDeleteBuffers(buffers_.size(), buffers_.data()); buffers_.fill(0);
|
||||
for (auto *&buffer : hardware_) { if (buffer) AHardwareBuffer_release(buffer); buffer = nullptr; }
|
||||
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
|
||||
}
|
||||
static int AllocationFd(AHardwareBuffer *buffer, uint64_t bytes, uint64_t &allocation) {
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, pair)) return -1;
|
||||
if (AHardwareBuffer_sendHandleToUnixSocket(buffer, pair[0])) { close(pair[0]); close(pair[1]); return -1; }
|
||||
close(pair[0]);
|
||||
int selected = -1; unsigned candidates = 0;
|
||||
for (;;) {
|
||||
unsigned char data[1024]; iovec bytesReceived{data, sizeof(data)};
|
||||
alignas(cmsghdr) unsigned char control[CMSG_SPACE(32 * sizeof(int))]{};
|
||||
msghdr message{}; message.msg_iov = &bytesReceived; message.msg_iovlen = 1;
|
||||
message.msg_control = control; message.msg_controllen = sizeof(control);
|
||||
ssize_t result = recvmsg(pair[1], &message, MSG_CMSG_CLOEXEC);
|
||||
if (result < 0 && errno == EINTR) continue;
|
||||
if (result <= 0) break;
|
||||
for (auto *item = CMSG_FIRSTHDR(&message); item; item = CMSG_NXTHDR(&message, item)) {
|
||||
if (item->cmsg_level != SOL_SOCKET || item->cmsg_type != SCM_RIGHTS) continue;
|
||||
auto *fds = reinterpret_cast<int *>(CMSG_DATA(item));
|
||||
for (unsigned index = 0; index < (item->cmsg_len - CMSG_LEN(0)) / sizeof(int); index++) {
|
||||
struct stat status{};
|
||||
if (!fstat(fds[index], &status) && status.st_size >= static_cast<int64_t>(bytes)
|
||||
&& status.st_size <= static_cast<int64_t>(bytes + 65536)) {
|
||||
candidates++; if (selected >= 0) close(selected);
|
||||
selected = fds[index]; allocation = status.st_size;
|
||||
} else close(fds[index]);
|
||||
}
|
||||
}
|
||||
if (message.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) { candidates = 0; break; }
|
||||
}
|
||||
close(pair[1]);
|
||||
if (candidates != 1) { if (selected >= 0) close(selected); return -1; }
|
||||
return selected;
|
||||
}
|
||||
bool Allocate(uint32_t width, uint32_t height) {
|
||||
DropPool(); width_ = width; height_ = height;
|
||||
const uint64_t bytes = uint64_t(width) * height * 4;
|
||||
GLint64 limit{}; glGetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &limit);
|
||||
if (bytes > static_cast<uint64_t>(limit)) return false;
|
||||
AHardwareBuffer_Desc description{}; description.width = bytes; description.height = description.layers = 1;
|
||||
description.format = AHARDWAREBUFFER_FORMAT_BLOB;
|
||||
description.usage = AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER;
|
||||
glGenBuffers(buffers_.size(), buffers_.data());
|
||||
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) {
|
||||
if (AHardwareBuffer_allocate(&description, &hardware_[index])) { DropPool(); return false; }
|
||||
fds_[index] = AllocationFd(hardware_[index], bytes, sizes_[index]);
|
||||
if (fds_[index] < 0) { DropPool(); return false; }
|
||||
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers_[index]);
|
||||
storage_(GL_SHADER_STORAGE_BUFFER, 0, bytes, client_(hardware_[index]), 0);
|
||||
if (glGetError() != GL_NO_ERROR) { DropPool(); return false; }
|
||||
}
|
||||
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
||||
return true;
|
||||
}
|
||||
bool Output() {
|
||||
if (raw_ && outputWidth_ == width_ && outputHeight_ == height_) return true;
|
||||
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
|
||||
glGenTextures(1, &raw_); glBindTexture(GL_TEXTURE_2D, raw_);
|
||||
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width_, height_);
|
||||
glGenTextures(1, &srgb_); view_(srgb_, GL_TEXTURE_2D, raw_, GL_SRGB8_ALPHA8, 0, 1, 0, 1);
|
||||
for (GLuint texture : {raw_, srgb_}) {
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
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);
|
||||
}
|
||||
outputWidth_ = width_; outputHeight_ = height_;
|
||||
return glGetError() == GL_NO_ERROR;
|
||||
}
|
||||
};
|
||||
@@ -1,777 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <android/log.h>
|
||||
#include <android_native_app_glue.h>
|
||||
#include <time.h>
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include "xr_tracking.h"
|
||||
#include "xr_frame_channel.h"
|
||||
#include "xr_frame_math.h"
|
||||
#include "gpu_frames.h"
|
||||
|
||||
static void check(XrResult result, const char *operation) {
|
||||
if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result));
|
||||
}
|
||||
|
||||
struct Eye {
|
||||
XrSwapchain swapchain = XR_NULL_HANDLE;
|
||||
int width = 0, height = 0;
|
||||
std::vector<XrSwapchainImageOpenGLESKHR> images;
|
||||
};
|
||||
|
||||
class QuestXR {
|
||||
public:
|
||||
explicit QuestXR(android_app *app) : app_(app) {}
|
||||
~QuestXR() { cleanup(); }
|
||||
|
||||
void run() {
|
||||
initialize();
|
||||
while (!app_->destroyRequested && !exit_) {
|
||||
android_poll_source *source = nullptr;
|
||||
int events = 0;
|
||||
int pollTimeout = running_ ? 0 : 100;
|
||||
for (int eventCount = 0; eventCount < 32; eventCount++) {
|
||||
if (ALooper_pollOnce(pollTimeout, nullptr, &events, reinterpret_cast<void **>(&source)) < 0) break;
|
||||
if (source) source->process(app_, source);
|
||||
if (app_->destroyRequested) break;
|
||||
pollTimeout = 0;
|
||||
}
|
||||
receiveFrame();
|
||||
pollEvents();
|
||||
if (running_ && !app_->destroyRequested) frame();
|
||||
}
|
||||
if (exit_ && !app_->destroyRequested) ANativeActivity_finish(app_->activity);
|
||||
}
|
||||
|
||||
void health(const char *error = "") {
|
||||
int steamvrState = -1;
|
||||
if (statusCallback_) {
|
||||
steamvrState = env_->CallIntMethod(app_->activity->clazz, statusCallback_);
|
||||
if (env_->ExceptionCheck()) { env_->ExceptionClear(); steamvrState = -1; }
|
||||
}
|
||||
std::string path = std::string(app_->activity->externalDataPath) + "/xr-health.json";
|
||||
std::string temp = path + ".tmp";
|
||||
FILE *file = std::fopen(temp.c_str(), "w");
|
||||
if (!file) return;
|
||||
std::fprintf(file,
|
||||
"{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu,"
|
||||
"\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s],"
|
||||
"\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"steamvrState\":%d,"
|
||||
"\"receivedFrames\":%llu,\"rejectedFrames\":%llu,\"uploadedFrames\":%llu,\"sourceFrameId\":%llu,"
|
||||
"\"gpuReceivedFrames\":%llu,\"gpuDroppedFrames\":%llu,\"gpuVerifiedFrameId\":%llu,\"gpuChecksum\":%u,\"error\":\"%s\"}\n",
|
||||
(unsigned long long)frames_, (unsigned long long)rendered_, state_,
|
||||
(unsigned long long)head_.locationFlags,
|
||||
head_.pose.position.x, head_.pose.position.y, head_.pose.position.z,
|
||||
handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false",
|
||||
(long long)period_, "false", "frames",
|
||||
0LL, steamvrState, (unsigned long long)(receiver_.Received() + gpuFrames_.Received()),
|
||||
(unsigned long long)(receiver_.Rejected() + gpuFrames_.Rejected()),
|
||||
(unsigned long long)uploadedFrames_, (unsigned long long)sourceHeader_.frame_id,
|
||||
(unsigned long long)gpuFrames_.Received(), (unsigned long long)gpuFrames_.Dropped(),
|
||||
(unsigned long long)verifiedFrame_, gpuChecksum_, error);
|
||||
std::fclose(file);
|
||||
std::rename(temp.c_str(), path.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
android_app *app_;
|
||||
JNIEnv *env_ = nullptr;
|
||||
XrInstance instance_ = XR_NULL_HANDLE;
|
||||
XrSession session_ = XR_NULL_HANDLE;
|
||||
XrSystemId system_ = XR_NULL_SYSTEM_ID;
|
||||
XrSpace world_ = XR_NULL_HANDLE, view_ = XR_NULL_HANDLE;
|
||||
XrActionSet actions_ = XR_NULL_HANDLE;
|
||||
XrAction grip_ = XR_NULL_HANDLE, trigger_ = XR_NULL_HANDLE, vibration_ = XR_NULL_HANDLE;
|
||||
XrAction squeeze_ = XR_NULL_HANDLE, stick_ = XR_NULL_HANDLE, stickClick_ = XR_NULL_HANDLE;
|
||||
XrAction primary_ = XR_NULL_HANDLE, secondary_ = XR_NULL_HANDLE, menu_ = XR_NULL_HANDLE;
|
||||
XrAction triggerTouch_ = XR_NULL_HANDLE, stickTouch_ = XR_NULL_HANDLE, primaryTouch_ = XR_NULL_HANDLE;
|
||||
XrAction secondaryTouch_ = XR_NULL_HANDLE, thumbrestTouch_ = XR_NULL_HANDLE;
|
||||
DroidDeckXrHand inputs_[2]{};
|
||||
PFN_xrConvertTimeToTimespecTimeKHR convertTime_ = nullptr;
|
||||
bool timespecSupported_ = false;
|
||||
PFN_xrPerfSettingsSetPerformanceLevelEXT setPerformance_ = nullptr;
|
||||
bool performanceSupported_ = false;
|
||||
XrPath hands_[2]{};
|
||||
XrSpace handSpaces_[2]{};
|
||||
XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION};
|
||||
XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}};
|
||||
bool handActive_[2]{};
|
||||
bool running_ = false, exit_ = false, quitRequested_ = false;
|
||||
bool guestAttached_ = false, verifyPixels_ = false;
|
||||
XrFrameReceiver receiver_;
|
||||
QuestGpuFrames gpuFrames_;
|
||||
DroidDeckXrFrameHeader sourceHeader_{};
|
||||
uint64_t uploadedFrames_ = 0, verifiedFrame_ = 0;
|
||||
uint32_t gpuChecksum_ = 0;
|
||||
GLuint sourceTexture_ = 0, sourceProgram_ = 0;
|
||||
GLuint cpuTexture_ = 0;
|
||||
jmethodID frameCallback_ = nullptr;
|
||||
jmethodID activeCallback_ = nullptr;
|
||||
jmethodID statusCallback_ = nullptr;
|
||||
DroidDeckXrTracking *tracking_ = nullptr;
|
||||
DroidDeckXrHaptics *haptics_ = nullptr;
|
||||
uint64_t lastPulse_[2]{};
|
||||
XrSessionState state_ = XR_SESSION_STATE_UNKNOWN;
|
||||
uint64_t frames_ = 0, rendered_ = 0;
|
||||
XrDuration period_ = 0;
|
||||
EGLDisplay display_ = EGL_NO_DISPLAY;
|
||||
EGLContext context_ = EGL_NO_CONTEXT;
|
||||
EGLSurface surface_ = EGL_NO_SURFACE;
|
||||
std::array<Eye, 2> eyes_;
|
||||
GLuint framebuffer_ = 0, vertexArray_ = 0;
|
||||
GLint sourceEncoding_ = 0, eyeEncoding_ = 0;
|
||||
|
||||
std::string captureRequest() {
|
||||
if (frames_ % 9) return {};
|
||||
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.request";
|
||||
FILE *request = std::fopen(file.c_str(), "r");
|
||||
if (!request) return {};
|
||||
char token[34]{};
|
||||
size_t size = std::fread(token, 1, sizeof(token) - 1, request);
|
||||
std::fclose(request);
|
||||
unlink(file.c_str());
|
||||
if (size != 32 || std::strspn(token, "0123456789abcdef") != 32) return {};
|
||||
unlink((std::string(app_->activity->externalDataPath) + "/xr-capture.json").c_str());
|
||||
return token;
|
||||
}
|
||||
|
||||
bool captureTexture(GLuint texture, int width, int height, const char *name) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
|
||||
GLint encoding = 0;
|
||||
glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
|
||||
GL_FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING, &encoding);
|
||||
if (!std::strcmp(name, "source")) sourceEncoding_ = encoding;
|
||||
else eyeEncoding_ = encoding;
|
||||
std::vector<unsigned char> pixels((size_t)width * height * 4);
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||||
bool ok = glGetError() == GL_NO_ERROR;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
if (!ok) return false;
|
||||
std::string file = std::string(app_->activity->externalDataPath) +
|
||||
"/xr-capture-" + name + ".ppm";
|
||||
std::string temporary = file + ".tmp";
|
||||
FILE *output = std::fopen(temporary.c_str(), "wb");
|
||||
if (!output) return false;
|
||||
ok = std::fprintf(output, "P6\n%d %d\n255\n", width, height) > 0;
|
||||
std::vector<unsigned char> row((size_t)width * 3);
|
||||
for (int y = height - 1; y >= 0 && ok; y--) {
|
||||
for (int x = 0; x < width; x++)
|
||||
std::memcpy(row.data() + x * 3, pixels.data() + ((size_t)y * width + x) * 4, 3);
|
||||
ok = std::fwrite(row.data(), 1, row.size(), output) == row.size();
|
||||
}
|
||||
if (std::fclose(output) != 0) ok = false;
|
||||
if (!ok) { unlink(temporary.c_str()); return false; }
|
||||
return std::rename(temporary.c_str(), file.c_str()) == 0;
|
||||
}
|
||||
|
||||
void captureResult(const std::string &token, bool ok, bool valid,
|
||||
const XrCompositionLayerProjectionView (&views)[2], XrTime displayTime) {
|
||||
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.json";
|
||||
std::string temporary = file + ".tmp";
|
||||
FILE *output = std::fopen(temporary.c_str(), "w");
|
||||
if (!output) return;
|
||||
std::fprintf(output, "{\"id\":\"%s\",\"ok\":%s,\"validLayer\":%s,\"frame\":%llu,"
|
||||
"\"sourceFrameId\":%llu,\"displayTime\":%lld,\"sessionState\":%d,\"eyes\":[",
|
||||
token.c_str(), ok ? "true" : "false", valid ? "true" : "false",
|
||||
(unsigned long long)frames_, (unsigned long long)sourceHeader_.frame_id, (long long)displayTime, state_);
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
const auto &pose = views[eye].pose;
|
||||
const auto &fov = views[eye].fov;
|
||||
std::fprintf(output, "%s{\"width\":%d,\"height\":%d,\"position\":[%g,%g,%g],"
|
||||
"\"orientation\":[%g,%g,%g,%g],\"fov\":[%g,%g,%g,%g]}",
|
||||
eye ? "," : "", eyes_[eye].width, eyes_[eye].height,
|
||||
pose.position.x, pose.position.y, pose.position.z,
|
||||
pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w,
|
||||
fov.angleLeft, fov.angleRight, fov.angleUp, fov.angleDown);
|
||||
}
|
||||
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
|
||||
const bool srgbControl = extensions && std::strstr(extensions, "GL_EXT_sRGB_write_control");
|
||||
std::fprintf(output, "],\"sourceImage\":true,\"sourceColorSpace\":\"%s\",\"sourceEncoding\":%d,\"eyeEncoding\":%d,"
|
||||
"\"srgbWriteControl\":%s,\"srgbWriteEnabled\":%s}\n",
|
||||
sourceHeader_.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? "srgb" : "linear", sourceEncoding_, eyeEncoding_,
|
||||
srgbControl ? "true" : "false", srgbControl && glIsEnabled(0x8DB9) ? "true" : "false");
|
||||
if (std::fclose(output) == 0) std::rename(temporary.c_str(), file.c_str());
|
||||
}
|
||||
|
||||
XrPath path(const char *text) {
|
||||
XrPath result{};
|
||||
check(xrStringToPath(instance_, text, &result), "xrStringToPath");
|
||||
return result;
|
||||
}
|
||||
|
||||
void initialize() {
|
||||
app_->userData = this;
|
||||
app_->onInputEvent = [](android_app *app, AInputEvent *event) -> int32_t {
|
||||
if (AInputEvent_getType(event) != AINPUT_EVENT_TYPE_KEY || AKeyEvent_getKeyCode(event) != AKEYCODE_BACK
|
||||
|| AKeyEvent_getAction(event) != AKEY_EVENT_ACTION_UP) return 0;
|
||||
auto *runtime = static_cast<QuestXR *>(app->userData);
|
||||
if (runtime->running_) {
|
||||
XrResult result = xrRequestExitSession(runtime->session_);
|
||||
if (XR_SUCCEEDED(result)) runtime->quitRequested_ = true;
|
||||
else __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "exit request failed: %d", result);
|
||||
} else ANativeActivity_finish(app->activity);
|
||||
return 1;
|
||||
};
|
||||
app_->activity->vm->AttachCurrentThread(&env_, nullptr);
|
||||
jclass activityClass = env_->GetObjectClass(app_->activity->clazz);
|
||||
jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;");
|
||||
jobject intent = env_->CallObjectMethod(app_->activity->clazz, getIntent);
|
||||
jclass intentClass = env_->GetObjectClass(intent);
|
||||
jmethodID getBoolean = env_->GetMethodID(intentClass, "getBooleanExtra", "(Ljava/lang/String;Z)Z");
|
||||
jstring extra = env_->NewStringUTF("frame_verify");
|
||||
verifyPixels_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE);
|
||||
env_->DeleteLocalRef(extra);
|
||||
activeCallback_ = env_->GetMethodID(activityClass, "onXrActive", "(Z)V");
|
||||
frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)V");
|
||||
statusCallback_ = env_->GetMethodID(activityClass, "onXrStatus", "()I");
|
||||
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); }
|
||||
env_->DeleteLocalRef(intentClass); env_->DeleteLocalRef(intent); env_->DeleteLocalRef(activityClass);
|
||||
PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
|
||||
check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&initializeLoader)), "loader init entrypoint");
|
||||
XrLoaderInitInfoAndroidKHR loader{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
|
||||
loader.applicationVM = app_->activity->vm;
|
||||
loader.applicationContext = app_->activity->clazz;
|
||||
check(initializeLoader(reinterpret_cast<XrLoaderInitInfoBaseHeaderKHR *>(&loader)), "xrInitializeLoaderKHR");
|
||||
uint32_t count = 0;
|
||||
check(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr), "extensions count");
|
||||
std::vector<XrExtensionProperties> extensions(count, {XR_TYPE_EXTENSION_PROPERTIES});
|
||||
check(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, extensions.data()), "extensions");
|
||||
for (const auto &extension : extensions) {
|
||||
if (!std::strcmp(extension.extensionName, XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME))
|
||||
timespecSupported_ = true;
|
||||
if (!std::strcmp(extension.extensionName, XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME))
|
||||
performanceSupported_ = true;
|
||||
}
|
||||
std::vector<const char *> enabled{XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
|
||||
if (timespecSupported_) enabled.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME);
|
||||
if (performanceSupported_) enabled.push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
|
||||
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
|
||||
androidInfo.applicationVM = app_->activity->vm;
|
||||
androidInfo.applicationActivity = app_->activity->clazz;
|
||||
XrInstanceCreateInfo create{XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
create.next = &androidInfo;
|
||||
std::strcpy(create.applicationInfo.applicationName, "DroidDeck XR");
|
||||
create.applicationInfo.applicationVersion = 1;
|
||||
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
|
||||
create.enabledExtensionCount = enabled.size();
|
||||
create.enabledExtensionNames = enabled.data();
|
||||
check(xrCreateInstance(&create, &instance_), "xrCreateInstance");
|
||||
if (performanceSupported_) {
|
||||
XrResult result = xrGetInstanceProcAddr(instance_, "xrPerfSettingsSetPerformanceLevelEXT",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&setPerformance_));
|
||||
if (XR_FAILED(result)) setPerformance_ = nullptr;
|
||||
}
|
||||
if (timespecSupported_) check(xrGetInstanceProcAddr(instance_, "xrConvertTimeToTimespecTimeKHR",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&convertTime_)), "time conversion entrypoint");
|
||||
XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
|
||||
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
|
||||
check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem");
|
||||
PFN_xrGetOpenGLESGraphicsRequirementsKHR requirementsFunction = nullptr;
|
||||
check(xrGetInstanceProcAddr(instance_, "xrGetOpenGLESGraphicsRequirementsKHR",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&requirementsFunction)), "GLES requirements entrypoint");
|
||||
XrGraphicsRequirementsOpenGLESKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
|
||||
check(requirementsFunction(instance_, system_, &requirements), "GLES requirements");
|
||||
if (requirements.minApiVersionSupported > XR_MAKE_VERSION(3, 0, 0)
|
||||
|| requirements.maxApiVersionSupported < XR_MAKE_VERSION(3, 0, 0))
|
||||
throw std::runtime_error("Runtime does not support GLES 3.0");
|
||||
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if (!eglInitialize(display_, nullptr, nullptr)) throw std::runtime_error("eglInitialize failed");
|
||||
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT | EGL_WINDOW_BIT,
|
||||
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE};
|
||||
EGLConfig config{};
|
||||
EGLint found = 0;
|
||||
if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1)
|
||||
throw std::runtime_error("EGL config unavailable");
|
||||
const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
|
||||
context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttributes);
|
||||
const EGLint surfaceAttributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
|
||||
surface_ = eglCreatePbufferSurface(display_, config, surfaceAttributes);
|
||||
if (!eglMakeCurrent(display_, surface_, surface_, context_)) throw std::runtime_error("eglMakeCurrent failed");
|
||||
XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
|
||||
binding.display = display_; binding.config = config; binding.context = context_;
|
||||
XrSessionCreateInfo sessionInfo{XR_TYPE_SESSION_CREATE_INFO};
|
||||
sessionInfo.next = &binding; sessionInfo.systemId = system_;
|
||||
check(xrCreateSession(instance_, &sessionInfo, &session_), "xrCreateSession");
|
||||
XrReferenceSpaceCreateInfo reference{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
reference.poseInReferenceSpace.orientation.w = 1;
|
||||
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
|
||||
XrResult referenceResult = xrCreateReferenceSpace(session_, &reference, &world_);
|
||||
if (referenceResult == XR_ERROR_REFERENCE_SPACE_UNSUPPORTED) {
|
||||
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||
reference.poseInReferenceSpace.position.y = -1.6f;
|
||||
check(xrCreateReferenceSpace(session_, &reference, &world_), "local standing space");
|
||||
} else check(referenceResult, "stage space");
|
||||
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
|
||||
reference.poseInReferenceSpace.position.y = 0;
|
||||
check(xrCreateReferenceSpace(session_, &reference, &view_), "view space");
|
||||
initializeActions();
|
||||
initializeImages();
|
||||
initializeGraphics();
|
||||
initializeTracking();
|
||||
{
|
||||
std::string filename = std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_FRAME_SOCKET;
|
||||
if (!receiver_.Start(filename)) throw std::runtime_error("XR frame receiver unavailable");
|
||||
bool gpu = !verifyPixels_ && gpuFrames_.Start(std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_GPU_SOCKET, display_);
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "GPU frame channel available=%d", gpu);
|
||||
}
|
||||
health();
|
||||
}
|
||||
|
||||
void initializeActions() {
|
||||
hands_[0] = path("/user/hand/left"); hands_[1] = path("/user/hand/right");
|
||||
XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
|
||||
std::strcpy(setInfo.actionSetName, "quest"); std::strcpy(setInfo.localizedActionSetName, "Quest controllers");
|
||||
check(xrCreateActionSet(instance_, &setInfo, &actions_), "action set");
|
||||
auto action = [&](const char *name, XrActionType type, XrAction *handle) {
|
||||
XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
|
||||
std::strcpy(info.actionName, name); std::strcpy(info.localizedActionName, name);
|
||||
info.actionType = type; info.countSubactionPaths = 2; info.subactionPaths = hands_;
|
||||
check(xrCreateAction(actions_, &info, handle), "create action");
|
||||
};
|
||||
action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_);
|
||||
action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_);
|
||||
action("squeeze", XR_ACTION_TYPE_FLOAT_INPUT, &squeeze_);
|
||||
action("stick", XR_ACTION_TYPE_VECTOR2F_INPUT, &stick_);
|
||||
action("stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickClick_);
|
||||
action("primary", XR_ACTION_TYPE_BOOLEAN_INPUT, &primary_);
|
||||
action("secondary", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondary_);
|
||||
action("menu", XR_ACTION_TYPE_BOOLEAN_INPUT, &menu_);
|
||||
action("trigger_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &triggerTouch_);
|
||||
action("stick_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickTouch_);
|
||||
action("primary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &primaryTouch_);
|
||||
action("secondary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondaryTouch_);
|
||||
action("thumbrest_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &thumbrestTouch_);
|
||||
action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_);
|
||||
std::vector<XrActionSuggestedBinding> bindings;
|
||||
for (const char *hand : {"left", "right"}) {
|
||||
std::string root = std::string("/user/hand/") + hand;
|
||||
bindings.push_back({grip_, path((root + "/input/grip/pose").c_str())});
|
||||
bindings.push_back({trigger_, path((root + "/input/trigger/value").c_str())});
|
||||
bindings.push_back({triggerTouch_, path((root + "/input/trigger/touch").c_str())});
|
||||
bindings.push_back({squeeze_, path((root + "/input/squeeze/value").c_str())});
|
||||
bindings.push_back({stick_, path((root + "/input/thumbstick").c_str())});
|
||||
bindings.push_back({stickClick_, path((root + "/input/thumbstick/click").c_str())});
|
||||
bindings.push_back({stickTouch_, path((root + "/input/thumbstick/touch").c_str())});
|
||||
bindings.push_back({thumbrestTouch_, path((root + "/input/thumbrest/touch").c_str())});
|
||||
bindings.push_back({primary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/click" : "/input/a/click")).c_str())});
|
||||
bindings.push_back({secondary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/click" : "/input/b/click")).c_str())});
|
||||
bindings.push_back({primaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/touch" : "/input/a/touch")).c_str())});
|
||||
bindings.push_back({secondaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/touch" : "/input/b/touch")).c_str())});
|
||||
bindings.push_back({vibration_, path((root + "/output/haptic").c_str())});
|
||||
}
|
||||
bindings.push_back({menu_, path("/user/hand/left/input/menu/click")});
|
||||
XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
|
||||
suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
|
||||
suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data();
|
||||
check(xrSuggestInteractionProfileBindings(instance_, &suggestion), "Touch bindings");
|
||||
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
|
||||
attach.countActionSets = 1; attach.actionSets = &actions_;
|
||||
check(xrAttachSessionActionSets(session_, &attach), "attach actions");
|
||||
for (int hand = 0; hand < 2; hand++) {
|
||||
XrActionSpaceCreateInfo space{XR_TYPE_ACTION_SPACE_CREATE_INFO};
|
||||
space.action = grip_; space.subactionPath = hands_[hand]; space.poseInActionSpace.orientation.w = 1;
|
||||
check(xrCreateActionSpace(session_, &space, &handSpaces_[hand]), "controller space");
|
||||
}
|
||||
}
|
||||
|
||||
void initializeImages() {
|
||||
uint32_t count = 0;
|
||||
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
|
||||
0, &count, nullptr), "view count");
|
||||
if (count != 2) throw std::runtime_error("Expected two stereo views");
|
||||
std::vector<XrViewConfigurationView> views(count, {XR_TYPE_VIEW_CONFIGURATION_VIEW});
|
||||
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
|
||||
count, &count, views.data()), "view configuration");
|
||||
check(xrEnumerateSwapchainFormats(session_, 0, &count, nullptr), "format count");
|
||||
std::vector<int64_t> formats(count);
|
||||
check(xrEnumerateSwapchainFormats(session_, count, &count, formats.data()), "formats");
|
||||
int64_t format = 0;
|
||||
for (int64_t candidate : formats) if (candidate == GL_SRGB8_ALPHA8) format = candidate;
|
||||
if (!format) throw std::runtime_error("sRGB swapchain unavailable");
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
Eye &target = eyes_[eye];
|
||||
target.width = views[eye].recommendedImageRectWidth;
|
||||
target.height = views[eye].recommendedImageRectHeight;
|
||||
XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
|
||||
create.format = format; create.sampleCount = 1;
|
||||
create.width = target.width; create.height = target.height;
|
||||
create.faceCount = 1; create.arraySize = 1; create.mipCount = 1;
|
||||
check(xrCreateSwapchain(session_, &create, &target.swapchain), "swapchain");
|
||||
check(xrEnumerateSwapchainImages(target.swapchain, 0, &count, nullptr), "image count");
|
||||
target.images.resize(count, {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR});
|
||||
check(xrEnumerateSwapchainImages(target.swapchain, count, &count,
|
||||
reinterpret_cast<XrSwapchainImageBaseHeader *>(target.images.data())), "images");
|
||||
}
|
||||
}
|
||||
|
||||
void initializeTracking() {
|
||||
std::string directory = std::string(app_->activity->internalDataPath) + "/.wayland-rt";
|
||||
if (mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST)
|
||||
throw std::runtime_error("XR tracking directory unavailable");
|
||||
auto mapFile = [&](const char *name, size_t size) {
|
||||
std::string filename = directory + "/" + name;
|
||||
int descriptor = open(filename.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
|
||||
if (descriptor < 0) throw std::runtime_error("XR IPC file unavailable");
|
||||
if (ftruncate(descriptor, size) != 0) {
|
||||
close(descriptor); throw std::runtime_error("XR IPC file size failed");
|
||||
}
|
||||
void *mapping = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0);
|
||||
close(descriptor);
|
||||
if (mapping == MAP_FAILED) throw std::runtime_error("XR IPC map failed");
|
||||
return mapping;
|
||||
};
|
||||
tracking_ = static_cast<DroidDeckXrTracking *>(mapFile(DROIDDECK_XR_FILE, sizeof(DroidDeckXrTracking)));
|
||||
haptics_ = static_cast<DroidDeckXrHaptics *>(mapFile(DROIDDECK_XR_HAPTIC_FILE, sizeof(DroidDeckXrHaptics)));
|
||||
haptics_->magic = DROIDDECK_XR_HAPTIC_MAGIC; haptics_->version = DROIDDECK_XR_VERSION;
|
||||
for (int hand = 0; hand < 2; hand++) lastPulse_[hand] = haptics_->hands[hand].id;
|
||||
}
|
||||
|
||||
static DroidDeckXrPose posePacket(const XrPosef &pose, XrSpaceLocationFlags flags) {
|
||||
return {{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w},
|
||||
{pose.position.x, pose.position.y, pose.position.z}, (uint32_t)flags};
|
||||
}
|
||||
|
||||
void publishTracking(const XrView views[2], XrTime predicted) {
|
||||
if (!tracking_) return;
|
||||
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
DroidDeckXrTracking packet{};
|
||||
packet.magic = DROIDDECK_XR_MAGIC; packet.version = DROIDDECK_XR_VERSION;
|
||||
packet.active = state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED;
|
||||
packet.sampled_ns = (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
|
||||
if (convertTime_) {
|
||||
timespec displayTime{};
|
||||
check(convertTime_(instance_, predicted, &displayTime), "display time conversion");
|
||||
packet.predicted_ns = (int64_t)displayTime.tv_sec * 1000000000LL + displayTime.tv_nsec;
|
||||
}
|
||||
packet.period_ns = period_;
|
||||
packet.head = posePacket(head_.pose, head_.locationFlags);
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
packet.eyes[eye].pose = posePacket(views[eye].pose, head_.locationFlags);
|
||||
packet.eyes[eye].fov[0] = views[eye].fov.angleLeft;
|
||||
packet.eyes[eye].fov[1] = views[eye].fov.angleRight;
|
||||
packet.eyes[eye].fov[2] = views[eye].fov.angleUp;
|
||||
packet.eyes[eye].fov[3] = views[eye].fov.angleDown;
|
||||
packet.eyes[eye].width = eyes_[eye].width; packet.eyes[eye].height = eyes_[eye].height;
|
||||
packet.hands[eye] = inputs_[eye];
|
||||
packet.hands[eye].pose = posePacket(handLocations_[eye].pose, handLocations_[eye].locationFlags);
|
||||
packet.hands[eye].active = handActive_[eye];
|
||||
}
|
||||
uint32_t sequence = __atomic_load_n(&tracking_->seq, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&tracking_->seq, sequence | 1u, __ATOMIC_SEQ_CST);
|
||||
__atomic_thread_fence(__ATOMIC_SEQ_CST);
|
||||
std::memcpy(tracking_, &packet, offsetof(DroidDeckXrTracking, seq));
|
||||
std::memcpy(reinterpret_cast<char *>(tracking_) + offsetof(DroidDeckXrTracking, active),
|
||||
reinterpret_cast<const char *>(&packet) + offsetof(DroidDeckXrTracking, active),
|
||||
sizeof(packet) - offsetof(DroidDeckXrTracking, active));
|
||||
__atomic_store_n(&tracking_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
void attachGuest(bool attach) {
|
||||
if (attach == guestAttached_) return;
|
||||
env_->CallVoidMethod(app_->activity->clazz, activeCallback_, static_cast<jboolean>(attach));
|
||||
if (env_->ExceptionCheck()) {
|
||||
env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest XR handshake failed");
|
||||
}
|
||||
guestAttached_ = attach;
|
||||
}
|
||||
|
||||
void pollHaptics() {
|
||||
if (!haptics_) return;
|
||||
DroidDeckXrHaptics packet{};
|
||||
uint32_t sequence = __atomic_load_n(&haptics_->seq, __ATOMIC_ACQUIRE);
|
||||
if (sequence & 1u) return;
|
||||
std::memcpy(&packet, haptics_, sizeof(packet));
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (__atomic_load_n(&haptics_->seq, __ATOMIC_RELAXED) != sequence
|
||||
|| packet.magic != DROIDDECK_XR_HAPTIC_MAGIC || packet.version != DROIDDECK_XR_VERSION) return;
|
||||
for (int hand = 0; hand < 2; hand++) {
|
||||
const auto &pulse = packet.hands[hand];
|
||||
if (pulse.id == lastPulse_[hand]) continue;
|
||||
lastPulse_[hand] = pulse.id;
|
||||
if (state_ != XR_SESSION_STATE_FOCUSED || !handActive_[hand]
|
||||
|| pulse.duration_ns < 1000000 || pulse.duration_ns > 1000000000
|
||||
|| !std::isfinite(pulse.amplitude) || pulse.amplitude < 0 || pulse.amplitude > 1
|
||||
|| !std::isfinite(pulse.frequency) || pulse.frequency < 0) continue;
|
||||
XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO}; info.action = vibration_; info.subactionPath = hands_[hand];
|
||||
XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION};
|
||||
vibration.duration = pulse.duration_ns; vibration.amplitude = pulse.amplitude; vibration.frequency = pulse.frequency;
|
||||
check(xrApplyHapticFeedback(session_, &info, reinterpret_cast<XrHapticBaseHeader *>(&vibration)), "guest haptics");
|
||||
}
|
||||
}
|
||||
|
||||
GLuint shader(GLenum type, const char *text) {
|
||||
GLuint handle = glCreateShader(type);
|
||||
glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle);
|
||||
GLint success = 0; glGetShaderiv(handle, GL_COMPILE_STATUS, &success);
|
||||
if (!success) { glDeleteShader(handle); throw std::runtime_error("Shader compile failed"); }
|
||||
return handle;
|
||||
}
|
||||
|
||||
void initializeGraphics() {
|
||||
glGenFramebuffers(1, &framebuffer_);
|
||||
glGenVertexArrays(1, &vertexArray_);
|
||||
GLuint vertex = shader(GL_VERTEX_SHADER,
|
||||
"#version 300 es\nout vec2 uv; uniform float eye; void main(){vec2 coordinate=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));uv=vec2(coordinate.x*0.5+eye*0.5,1.0-coordinate.y);gl_Position=vec4(coordinate*2.0-1.0,0.0,1.0);}");
|
||||
GLuint fragment = shader(GL_FRAGMENT_SHADER,
|
||||
"#version 300 es\nprecision highp float; in vec2 uv; uniform sampler2D source; uniform float eye; out vec4 outputColor; void main(){float edge=0.5/float(textureSize(source,0).x);vec2 coordinate=vec2(clamp(uv.x,eye*0.5+edge,(eye+1.0)*0.5-edge),uv.y);outputColor=texture(source,coordinate);}");
|
||||
sourceProgram_ = glCreateProgram(); glAttachShader(sourceProgram_, vertex); glAttachShader(sourceProgram_, fragment);
|
||||
glLinkProgram(sourceProgram_); glDeleteShader(vertex); glDeleteShader(fragment);
|
||||
GLint linked = 0; glGetProgramiv(sourceProgram_, GL_LINK_STATUS, &linked);
|
||||
if (!linked) throw std::runtime_error("Frame shader link failed");
|
||||
glGenTextures(1, &sourceTexture_); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
|
||||
cpuTexture_ = sourceTexture_;
|
||||
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);
|
||||
}
|
||||
|
||||
void receiveFrame() {
|
||||
if (gpuFrames_.Take(sourceHeader_, sourceTexture_)) {
|
||||
uploadedFrames_++;
|
||||
if (uploadedFrames_ % 72 == 1) health();
|
||||
return;
|
||||
}
|
||||
DroidDeckXrPixels frame;
|
||||
if (!receiver_.Take(&frame)) return;
|
||||
GLint maximum = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum);
|
||||
if (frame.header.width > static_cast<uint32_t>(maximum) || frame.header.height > static_cast<uint32_t>(maximum))
|
||||
throw std::runtime_error("XR source exceeds GPU texture limit");
|
||||
sourceTexture_ = cpuTexture_;
|
||||
glBindTexture(GL_TEXTURE_2D, sourceTexture_);
|
||||
GLenum encoding = frame.header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? GL_SRGB8_ALPHA8 : GL_RGBA8;
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, encoding, frame.header.width, frame.header.height, 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, frame.pixels.data());
|
||||
if (verifyPixels_) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, 0);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sourceTexture_, 0);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
|
||||
throw std::runtime_error("Frame readback framebuffer incomplete");
|
||||
std::vector<unsigned char> readback(frame.header.bytes);
|
||||
glReadPixels(0, 0, frame.header.width, frame.header.height, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
|
||||
if (readback != frame.pixels) throw std::runtime_error("Frame GPU readback does not match owned pixels");
|
||||
gpuChecksum_ = 2166136261u;
|
||||
for (unsigned char value : readback) gpuChecksum_ = (gpuChecksum_ ^ value) * 16777619u;
|
||||
verifiedFrame_ = frame.header.frame_id;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("Frame texture upload failed");
|
||||
sourceHeader_ = frame.header;
|
||||
receiver_.Recycle(std::move(frame.pixels));
|
||||
uploadedFrames_++;
|
||||
if (verifyPixels_ || uploadedFrames_ % 72 == 1) health();
|
||||
}
|
||||
|
||||
void render(Eye &eye, uint32_t image) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, eye.images[image].image, 0);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
|
||||
throw std::runtime_error("Incomplete eye framebuffer");
|
||||
{
|
||||
glViewport(0, 0, eye.width, eye.height); glDisable(GL_DEPTH_TEST);
|
||||
glUseProgram(sourceProgram_); glBindVertexArray(vertexArray_);
|
||||
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
|
||||
glUniform1i(glGetUniformLocation(sourceProgram_, "source"), 0);
|
||||
glUniform1f(glGetUniformLocation(sourceProgram_, "eye"), &eye == &eyes_[1] ? 1.f : 0.f);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
}
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
|
||||
glFlush();
|
||||
}
|
||||
|
||||
void pollEvents() {
|
||||
XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
|
||||
XrResult result;
|
||||
while ((result = xrPollEvent(instance_, &event)) == XR_SUCCESS) {
|
||||
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
|
||||
state_ = reinterpret_cast<XrEventDataSessionStateChanged *>(&event)->state;
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_);
|
||||
attachGuest(state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED);
|
||||
if (state_ == XR_SESSION_STATE_READY) {
|
||||
XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
|
||||
begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
check(xrBeginSession(session_, &begin), "xrBeginSession"); running_ = true;
|
||||
if (setPerformance_) {
|
||||
for (auto domain : {XR_PERF_SETTINGS_DOMAIN_CPU_EXT, XR_PERF_SETTINGS_DOMAIN_GPU_EXT}) {
|
||||
XrResult request = setPerformance_(session_, domain, XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT);
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "sustained performance domain=%d result=%d", domain, request);
|
||||
}
|
||||
}
|
||||
} else if (state_ == XR_SESSION_STATE_STOPPING) {
|
||||
check(xrEndSession(session_), "xrEndSession"); running_ = false;
|
||||
if (quitRequested_) exit_ = true;
|
||||
} else if (state_ == XR_SESSION_STATE_EXITING || state_ == XR_SESSION_STATE_LOSS_PENDING) exit_ = true;
|
||||
health();
|
||||
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) exit_ = true;
|
||||
else if (event.type == XR_TYPE_EVENT_DATA_PERF_SETTINGS_EXT) {
|
||||
const auto &performance = *reinterpret_cast<XrEventDataPerfSettingsEXT *>(&event);
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "performance domain=%d subdomain=%d level=%d->%d",
|
||||
performance.domain, performance.subDomain, performance.fromLevel, performance.toLevel);
|
||||
}
|
||||
event = {XR_TYPE_EVENT_DATA_BUFFER};
|
||||
}
|
||||
check(result, "xrPollEvent");
|
||||
}
|
||||
|
||||
void frame() {
|
||||
XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};
|
||||
XrFrameState frameState{XR_TYPE_FRAME_STATE};
|
||||
check(xrWaitFrame(session_, &wait, &frameState), "xrWaitFrame");
|
||||
period_ = frameState.predictedDisplayPeriod;
|
||||
XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO}; check(xrBeginFrame(session_, &begin), "xrBeginFrame");
|
||||
check(xrLocateSpace(view_, world_, frameState.predictedDisplayTime, &head_), "head pose");
|
||||
if (state_ == XR_SESSION_STATE_FOCUSED) {
|
||||
XrActiveActionSet active{actions_, XR_NULL_PATH};
|
||||
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO}; sync.countActiveActionSets = 1; sync.activeActionSets = &active;
|
||||
check(xrSyncActions(session_, &sync), "xrSyncActions");
|
||||
for (int hand = 0; hand < 2; hand++) {
|
||||
XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO}; get.action = grip_; get.subactionPath = hands_[hand];
|
||||
XrActionStatePose pose{XR_TYPE_ACTION_STATE_POSE}; check(xrGetActionStatePose(session_, &get, &pose), "grip state");
|
||||
handActive_[hand] = pose.isActive;
|
||||
check(xrLocateSpace(handSpaces_[hand], world_, frameState.predictedDisplayTime, &handLocations_[hand]), "hand pose");
|
||||
get.action = trigger_;
|
||||
XrActionStateFloat trigger{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &trigger), "trigger state");
|
||||
inputs_[hand].trigger = trigger.isActive ? trigger.currentState : 0;
|
||||
get.action = squeeze_;
|
||||
XrActionStateFloat squeeze{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &squeeze), "squeeze state");
|
||||
inputs_[hand].squeeze = squeeze.isActive ? squeeze.currentState : 0;
|
||||
get.action = stick_;
|
||||
XrActionStateVector2f stick{XR_TYPE_ACTION_STATE_VECTOR2F}; check(xrGetActionStateVector2f(session_, &get, &stick), "stick state");
|
||||
inputs_[hand].stick[0] = stick.isActive ? stick.currentState.x : 0;
|
||||
inputs_[hand].stick[1] = stick.isActive ? stick.currentState.y : 0;
|
||||
inputs_[hand].buttons = 0;
|
||||
const std::pair<XrAction, uint32_t> buttons[] = {
|
||||
{stickClick_, DROIDDECK_XR_STICK_CLICK}, {primary_, DROIDDECK_XR_PRIMARY_CLICK},
|
||||
{secondary_, DROIDDECK_XR_SECONDARY_CLICK}, {menu_, DROIDDECK_XR_MENU_CLICK},
|
||||
{triggerTouch_, DROIDDECK_XR_TRIGGER_TOUCH}, {stickTouch_, DROIDDECK_XR_STICK_TOUCH},
|
||||
{primaryTouch_, DROIDDECK_XR_PRIMARY_TOUCH}, {secondaryTouch_, DROIDDECK_XR_SECONDARY_TOUCH},
|
||||
{thumbrestTouch_, DROIDDECK_XR_THUMBREST_TOUCH}};
|
||||
for (const auto &button : buttons) {
|
||||
get.action = button.first;
|
||||
XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; check(xrGetActionStateBoolean(session_, &get, &value), "button state");
|
||||
if (value.isActive && value.currentState) inputs_[hand].buttons |= button.second;
|
||||
}
|
||||
}
|
||||
} else handActive_[0] = handActive_[1] = false;
|
||||
pollHaptics();
|
||||
XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO}; locate.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
locate.displayTime = frameState.predictedDisplayTime; locate.space = world_;
|
||||
XrViewState viewState{XR_TYPE_VIEW_STATE}; uint32_t count = 0;
|
||||
XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
|
||||
check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews");
|
||||
publishTracking(views, frameState.predictedDisplayTime);
|
||||
if (guestAttached_) {
|
||||
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
env_->CallVoidMethod(app_->activity->clazz, frameCallback_, (jlong)now.tv_sec * 1000000000LL + now.tv_nsec);
|
||||
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("Guest frame callback failed"); }
|
||||
}
|
||||
XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
|
||||
bool valid = frameState.shouldRender && count == 2 &&
|
||||
(viewState.viewStateFlags & (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT))
|
||||
== (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT);
|
||||
{
|
||||
valid = valid && sourceHeader_.frame_id && xr_frame_now() - sourceHeader_.captured_ns < 500000000LL;
|
||||
if (valid) for (int eye = 0; eye < 2; eye++) {
|
||||
auto source = xr_frame_view(sourceHeader_, eye);
|
||||
views[eye].pose.orientation = {source.orientation[0], source.orientation[1], source.orientation[2], source.orientation[3]};
|
||||
views[eye].pose.position = {source.position[0], source.position[1], source.position[2]};
|
||||
views[eye].fov = {source.fov[0], source.fov[1], source.fov[2], source.fov[3]};
|
||||
}
|
||||
}
|
||||
const std::string capture = captureRequest();
|
||||
bool captured = valid;
|
||||
if (!capture.empty() && valid)
|
||||
captured = captureTexture(sourceTexture_, sourceHeader_.width, sourceHeader_.height, "source");
|
||||
if (valid) for (int eye = 0; eye < 2; eye++) {
|
||||
uint32_t image = 0;
|
||||
XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
||||
check(xrAcquireSwapchainImage(eyes_[eye].swapchain, &acquire, &image), "acquire image");
|
||||
XrSwapchainImageWaitInfo imageWait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; imageWait.timeout = XR_INFINITE_DURATION;
|
||||
check(xrWaitSwapchainImage(eyes_[eye].swapchain, &imageWait), "wait image");
|
||||
render(eyes_[eye], image);
|
||||
if (!capture.empty()) captured = captureTexture(eyes_[eye].images[image].image, eyes_[eye].width, eyes_[eye].height, eye ? "right" : "left") && captured;
|
||||
XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
||||
check(xrReleaseSwapchainImage(eyes_[eye].swapchain, &release), "release image");
|
||||
projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov;
|
||||
projection[eye].subImage.swapchain = eyes_[eye].swapchain;
|
||||
projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height};
|
||||
}
|
||||
XrCompositionLayerProjection layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
|
||||
layer.space = world_; layer.viewCount = 2; layer.views = projection;
|
||||
const XrCompositionLayerBaseHeader *layers[] = {reinterpret_cast<XrCompositionLayerBaseHeader *>(&layer)};
|
||||
XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO}; end.displayTime = frameState.predictedDisplayTime;
|
||||
end.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; end.layerCount = valid ? 1 : 0; end.layers = layers;
|
||||
check(xrEndFrame(session_, &end), "xrEndFrame");
|
||||
if (!capture.empty()) captureResult(capture, captured, valid, projection, frameState.predictedDisplayTime);
|
||||
frames_++; if (valid) rendered_++;
|
||||
if (frames_ % 72 == 0) {
|
||||
health();
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "frames=%llu rendered=%llu head=%llu hands=%d,%d period=%lld",
|
||||
(unsigned long long)frames_, (unsigned long long)rendered_, (unsigned long long)head_.locationFlags,
|
||||
handActive_[0], handActive_[1], (long long)period_);
|
||||
}
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
receiver_.Stop();
|
||||
gpuFrames_.Stop();
|
||||
try { attachGuest(false); } catch (const std::exception &error) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "cleanup: %s", error.what());
|
||||
}
|
||||
if (tracking_) {
|
||||
__atomic_store_n(&tracking_->active, 0u, __ATOMIC_RELEASE);
|
||||
munmap(tracking_, sizeof(DroidDeckXrTracking));
|
||||
}
|
||||
if (haptics_) munmap(haptics_, sizeof(DroidDeckXrHaptics));
|
||||
if (display_ != EGL_NO_DISPLAY && context_ != EGL_NO_CONTEXT) {
|
||||
glDeleteFramebuffers(1, &framebuffer_); glDeleteVertexArrays(1, &vertexArray_);
|
||||
glDeleteTextures(1, &cpuTexture_); glDeleteProgram(sourceProgram_);
|
||||
}
|
||||
for (auto &eye : eyes_) if (eye.swapchain) xrDestroySwapchain(eye.swapchain);
|
||||
for (auto space : handSpaces_) if (space) xrDestroySpace(space);
|
||||
if (view_) xrDestroySpace(view_); if (world_) xrDestroySpace(world_);
|
||||
if (session_) xrDestroySession(session_);
|
||||
if (actions_) xrDestroyActionSet(actions_);
|
||||
if (instance_) xrDestroyInstance(instance_);
|
||||
if (display_ != EGL_NO_DISPLAY) {
|
||||
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
if (surface_ != EGL_NO_SURFACE) eglDestroySurface(display_, surface_);
|
||||
if (context_ != EGL_NO_CONTEXT) eglDestroyContext(display_, context_);
|
||||
eglTerminate(display_);
|
||||
}
|
||||
if (env_) app_->activity->vm->DetachCurrentThread();
|
||||
}
|
||||
};
|
||||
|
||||
void android_main(android_app *app) {
|
||||
QuestXR runtime(app);
|
||||
try { runtime.run(); }
|
||||
catch (const std::exception &error) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "%s", error.what());
|
||||
runtime.health(error.what());
|
||||
ANativeActivity_finish(app->activity);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// Our controller uses the standard display-mode vocabulary, even for Beast.
|
||||
// Native mode reuses some numbers with different meanings; never pass them through.
|
||||
namespace droiddeck::viture {
|
||||
constexpr int to_native_mode(int standard) {
|
||||
switch (standard) {
|
||||
case 0x31: return 0x31; // 1080p 60
|
||||
case 0x33: return 0x32; // 1080p 90
|
||||
case 0x34: return 0x33; // 1080p 120
|
||||
case 0x32: return 0x37; // 1080p SBS 60
|
||||
case 0x35: return 0x38; // 1080p SBS 90
|
||||
case 0x41: return 0x34; // 1200p 60
|
||||
case 0x43: return 0x35; // 1200p 90
|
||||
case 0x44: return 0x36; // 1200p 120
|
||||
case 0x42: return 0x3a; // 1200p SBS 60
|
||||
case 0x45: return 0x3b; // 1200p SBS 90
|
||||
default: return -4;
|
||||
}
|
||||
}
|
||||
constexpr int from_native_mode(int native) {
|
||||
switch (native) {
|
||||
case 0x31: return 0x31;
|
||||
case 0x32: return 0x33;
|
||||
case 0x33: return 0x34;
|
||||
case 0x37: return 0x32;
|
||||
case 0x38: return 0x35;
|
||||
case 0x34: return 0x41;
|
||||
case 0x35: return 0x43;
|
||||
case 0x36: return 0x44;
|
||||
case 0x3a: return 0x42;
|
||||
case 0x3b: return 0x45;
|
||||
default: return -4; // Ultrawide is not stereo.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <time.h>
|
||||
#include "viture_glasses_provider.h"
|
||||
#include "viture_device.h"
|
||||
#include "viture_device_carina.h"
|
||||
#include "viture_protocol_public.h"
|
||||
#include "viture_result.h"
|
||||
#include "viture_version.h"
|
||||
#include "display_modes.h"
|
||||
|
||||
namespace {
|
||||
// SDK callbacks have no user-data parameter. Only one provider may exist at a time.
|
||||
std::mutex api_mutex;
|
||||
std::mutex pose_mutex;
|
||||
std::array<float, 4> latest_pose{};
|
||||
int64_t latest_time = 0;
|
||||
bool accepting_pose = false;
|
||||
bool accepting_state = false;
|
||||
std::array<int, 6> state_values{};
|
||||
unsigned state_pending = 0;
|
||||
|
||||
void on_state(int id, int value) {
|
||||
std::lock_guard<std::mutex> lock(pose_mutex);
|
||||
if (!accepting_state || id < 0 || id >= int(state_values.size())) return;
|
||||
state_values[id] = value;
|
||||
state_pending |= 1u << id;
|
||||
}
|
||||
|
||||
void clear_state(bool accept) {
|
||||
std::lock_guard<std::mutex> lock(pose_mutex);
|
||||
accepting_state = accept;
|
||||
state_pending = 0;
|
||||
}
|
||||
|
||||
int64_t now_ns() {
|
||||
timespec ts{};
|
||||
clock_gettime(CLOCK_BOOTTIME, &ts);
|
||||
return int64_t(ts.tv_sec) * 1000000000LL + ts.tv_nsec;
|
||||
}
|
||||
|
||||
void on_pose(float* data, uint64_t) {
|
||||
if (!data) return;
|
||||
std::lock_guard<std::mutex> lock(pose_mutex);
|
||||
if (!accepting_pose) return;
|
||||
std::copy_n(data + 3, 4, latest_pose.begin());
|
||||
// SDK device ticks are not Android's elapsed-realtime clock.
|
||||
latest_time = now_ns();
|
||||
}
|
||||
|
||||
void clear_pose(bool accept) {
|
||||
std::lock_guard<std::mutex> lock(pose_mutex);
|
||||
accepting_pose = accept;
|
||||
latest_time = 0;
|
||||
}
|
||||
|
||||
struct Session {
|
||||
XRDeviceProviderHandle device = nullptr;
|
||||
int pid = 0;
|
||||
int type = -1;
|
||||
bool initialized = false;
|
||||
bool started = false;
|
||||
bool tracking = false;
|
||||
bool native_dof = false;
|
||||
bool switched_native = false;
|
||||
int original_bypass_display = -1;
|
||||
int saved_dof = -1;
|
||||
int64_t consumed_time = 0;
|
||||
jlong token = 0;
|
||||
};
|
||||
std::unique_ptr<Session> session;
|
||||
jlong next_token = 0;
|
||||
|
||||
void report(const char* step, int rc) {
|
||||
if (rc < 0) __android_log_print(ANDROID_LOG_WARN, "VitureSdk", "%s: %d", step, rc);
|
||||
}
|
||||
|
||||
int suspend_native_dof(Session& s) {
|
||||
if (!s.native_dof) return 0;
|
||||
int mode = xr_device_provider_native_get_mode(s.device);
|
||||
if (mode < 0) return mode;
|
||||
if (mode != 1) return 0;
|
||||
if (s.saved_dof < 0) {
|
||||
int dof = xr_device_provider_native_get_dof(s.device);
|
||||
if (dof < 0) return dof;
|
||||
s.saved_dof = dof;
|
||||
}
|
||||
return xr_device_provider_native_set_dof(s.device, VITURE_NATIVE_DOF_0);
|
||||
}
|
||||
|
||||
void restore_native_dof(Session& s) {
|
||||
if (s.saved_dof >= 0 && xr_device_provider_native_get_mode(s.device) == 1) {
|
||||
report("restore native DOF", xr_device_provider_native_set_dof(s.device, s.saved_dof));
|
||||
}
|
||||
s.saved_dof = -1;
|
||||
}
|
||||
|
||||
int tracking(Session& s, bool enabled) {
|
||||
if (s.tracking == enabled) return 0;
|
||||
if (!enabled) {
|
||||
clear_pose(false);
|
||||
int rc = s.type == XR_DEVICE_TYPE_VITURE_CARINA ? 0 :
|
||||
xr_device_provider_close_imu(s.device, VITURE_IMU_MODE_POSE);
|
||||
s.tracking = false;
|
||||
restore_native_dof(s);
|
||||
return rc;
|
||||
}
|
||||
int rc = suspend_native_dof(s);
|
||||
if (rc < 0) return rc;
|
||||
clear_pose(true);
|
||||
s.consumed_time = 0;
|
||||
if (s.type != XR_DEVICE_TYPE_VITURE_CARINA) {
|
||||
int frequency = VITURE_IMU_FREQUENCY_MEDIUM_HIGH;
|
||||
while (frequency >= VITURE_IMU_FREQUENCY_LOW &&
|
||||
!xr_device_provider_is_product_support_imu_frequency(s.pid, VITURE_IMU_MODE_POSE, frequency)) --frequency;
|
||||
rc = frequency < 0 ? VITURE_GLASSES_ERROR_NOT_SUPPORTED :
|
||||
xr_device_provider_open_imu(s.device, VITURE_IMU_MODE_POSE, frequency);
|
||||
}
|
||||
if (rc == 0) s.tracking = true;
|
||||
else { clear_pose(false); restore_native_dof(s); }
|
||||
return rc;
|
||||
}
|
||||
|
||||
void destroy(Session& s) {
|
||||
clear_pose(false);
|
||||
clear_state(false);
|
||||
if (s.device) {
|
||||
if (s.tracking) report("close tracking", tracking(s, false));
|
||||
if (s.switched_native && s.original_bypass_display >= 0) {
|
||||
int rc = xr_device_provider_native_set_mode(s.device, 0);
|
||||
if (rc == 0) rc = xr_device_provider_set_display_mode(s.device, s.original_bypass_display);
|
||||
report("restore bypass mode", rc);
|
||||
}
|
||||
if (s.started) report("stop", xr_device_provider_stop(s.device));
|
||||
if (s.initialized) report("shutdown", xr_device_provider_shutdown(s.device));
|
||||
xr_device_provider_destroy(s.device);
|
||||
s.device = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
int get_display(Session& s) {
|
||||
if (s.native_dof) {
|
||||
const int mode = xr_device_provider_native_get_mode(s.device);
|
||||
if (mode < 0) return mode;
|
||||
if (mode == 1) {
|
||||
const int value = xr_device_provider_native_get_display_mode(s.device);
|
||||
return value < 0 ? value : droiddeck::viture::from_native_mode(value);
|
||||
}
|
||||
}
|
||||
return xr_device_provider_get_display_mode(s.device);
|
||||
}
|
||||
|
||||
int set_display(Session& s, int standard) {
|
||||
if (!s.native_dof) return xr_device_provider_set_display_mode(s.device, standard);
|
||||
int mode = xr_device_provider_native_get_mode(s.device);
|
||||
if (mode < 0) return mode;
|
||||
if (mode == 0) {
|
||||
int rc = xr_device_provider_set_display_mode(s.device, standard);
|
||||
if (rc != VITURE_GLASSES_ERROR_NOT_SUPPORTED) return rc;
|
||||
// Older Beast firmware exposes fewer bypass modes. Fall back to its
|
||||
// separate native table only when the SDK explicitly rejects the mode.
|
||||
}
|
||||
const int native = droiddeck::viture::to_native_mode(standard);
|
||||
if (native < 0) return native;
|
||||
if (mode == 0) {
|
||||
if (!s.switched_native) {
|
||||
s.original_bypass_display = xr_device_provider_get_display_mode(s.device);
|
||||
if (s.original_bypass_display < 0) return s.original_bypass_display;
|
||||
}
|
||||
int rc = xr_device_provider_native_set_mode(s.device, 1);
|
||||
if (rc < 0) return rc;
|
||||
s.switched_native = true;
|
||||
}
|
||||
int rc = xr_device_provider_native_set_display_mode(s.device, native);
|
||||
if (rc == 0 && s.tracking) rc = suspend_native_dof(s);
|
||||
if (rc < 0 && mode == 0) {
|
||||
restore_native_dof(s);
|
||||
int rollback = xr_device_provider_native_set_mode(s.device, 0);
|
||||
if (rollback == 0) rollback = xr_device_provider_set_display_mode(s.device, s.original_bypass_display);
|
||||
report("rollback display mode", rollback);
|
||||
if (rollback == 0) s.switched_native = false;
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
|
||||
#define JNI_METHOD(name) Java_com_droiddeck_launcher_glasses_VitureSdk_##name
|
||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nativeAvailable)(JNIEnv*, jobject) {
|
||||
__android_log_print(ANDROID_LOG_INFO, "VitureSdk", "SDK %s", GetVersionString());
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nativeMarketName)(JNIEnv* env, jobject, jint pid) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
if (!xr_device_provider_is_product_id_valid(pid)) return nullptr;
|
||||
char name[64]{};
|
||||
int length = sizeof(name) - 1;
|
||||
if (xr_device_provider_get_market_name(pid, name, &length) != 0) return nullptr;
|
||||
name[sizeof(name) - 1] = 0;
|
||||
return env->NewStringUTF(name);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativeOpen)(JNIEnv* env, jobject, jint pid, jint fd, jstring cache) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
if (session || fd < 0 || !cache || !xr_device_provider_is_product_id_valid(pid)) return 0;
|
||||
auto s = std::make_unique<Session>();
|
||||
s->pid = pid;
|
||||
s->device = xr_device_provider_create(pid, fd);
|
||||
if (!s->device) return 0;
|
||||
s->type = xr_device_provider_get_device_type(s->device);
|
||||
s->native_dof = xr_device_provider_is_product_support_native_dof(pid) == 1;
|
||||
int rc = s->type < 0 ? s->type : 0;
|
||||
if (rc == 0) {
|
||||
clear_state(true);
|
||||
rc = xr_device_provider_register_state_callback(s->device, on_state);
|
||||
}
|
||||
if (rc == 0) {
|
||||
rc = s->type == XR_DEVICE_TYPE_VITURE_CARINA ?
|
||||
xr_device_provider_set_dof_type_carina(s->device, 0) :
|
||||
xr_device_provider_register_imu_pose_callback(s->device, on_pose);
|
||||
}
|
||||
if (rc == 0) {
|
||||
const char* directory = env->GetStringUTFChars(cache, nullptr);
|
||||
if (!directory) { destroy(*s); return 0; }
|
||||
rc = xr_device_provider_initialize(s->device, nullptr, directory);
|
||||
env->ReleaseStringUTFChars(cache, directory);
|
||||
s->initialized = rc == 0;
|
||||
}
|
||||
if (rc == 0) {
|
||||
rc = xr_device_provider_start(s->device);
|
||||
s->started = rc == 0;
|
||||
}
|
||||
if (rc != 0) { report("initialize/start", rc); destroy(*s); return 0; }
|
||||
s->token = ++next_token;
|
||||
session = std::move(s);
|
||||
return session->token;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeType)(JNIEnv*, jobject, jlong token) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
return session && session->token == token ? session->type : -1;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeTracking)(JNIEnv*, jobject, jlong token, jboolean enabled) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
return session && session->token == token ? tracking(*session, enabled) : -1;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeCommand)(JNIEnv*, jobject, jlong token, jint command, jint value) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
if (!session || session->token != token) return -1;
|
||||
auto& s = *session;
|
||||
switch (command) {
|
||||
case 1: return get_display(s);
|
||||
case 2: return set_display(s, value);
|
||||
case 3: return xr_device_provider_get_brightness_level(s.device);
|
||||
case 4: return xr_device_provider_set_brightness_level(s.device, value);
|
||||
case 5: return xr_device_provider_get_volume_level(s.device);
|
||||
case 6: return xr_device_provider_set_volume_level(s.device, value);
|
||||
case 7: return xr_device_provider_set_film_mode(s.device, value ? 1.0f : 0.0f);
|
||||
default: return VITURE_GLASSES_ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativePose)(JNIEnv* env, jobject, jlong token, jfloatArray output) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
if (!session || session->token != token || !output || env->GetArrayLength(output) != 4) return -1;
|
||||
auto& s = *session;
|
||||
if (!s.tracking) return 0;
|
||||
if (s.type == XR_DEVICE_TYPE_VITURE_CARINA) {
|
||||
float pose[7]{};
|
||||
int status = 1;
|
||||
int rc = xr_device_provider_get_gl_pose_carina(s.device, pose, 0.0, &status);
|
||||
if (rc < 0) return rc;
|
||||
if (status != 0) return 0;
|
||||
env->SetFloatArrayRegion(output, 0, 4, pose + 3);
|
||||
return now_ns();
|
||||
}
|
||||
std::lock_guard<std::mutex> pose_lock(pose_mutex);
|
||||
if (!latest_time || latest_time == s.consumed_time) return 0;
|
||||
env->SetFloatArrayRegion(output, 0, 4, latest_pose.data());
|
||||
s.consumed_time = latest_time;
|
||||
return latest_time;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeNextState)(JNIEnv* env, jobject, jlong token, jintArray output) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
if (!session || session->token != token || !output || env->GetArrayLength(output) != 2) return -1;
|
||||
std::lock_guard<std::mutex> state_lock(pose_mutex);
|
||||
for (int id = 0; id < int(state_values.size()); ++id) {
|
||||
if (!(state_pending & (1u << id))) continue;
|
||||
state_pending &= ~(1u << id);
|
||||
const jint value[] = {id, state_values[id]};
|
||||
env->SetIntArrayRegion(output, 0, 2, value);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeClose)(JNIEnv*, jobject, jlong token) {
|
||||
std::lock_guard<std::mutex> lock(api_mutex);
|
||||
if (session && session->token == token) { destroy(*session); session.reset(); }
|
||||
}
|
||||
@@ -34,19 +34,16 @@ void cardboard_clamp(struct cardboard_params *p) {
|
||||
p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f);
|
||||
p->k1 = clampf(p->k1, -1.0f, 2.0f);
|
||||
p->k2 = clampf(p->k2, -1.0f, 2.0f);
|
||||
// Optical vertical FOV on supported glasses can be as low as ~22 degrees.
|
||||
p->fov_deg = clampf(p->fov_deg, 20.0f, 140.0f);
|
||||
p->fov_deg = clampf(p->fov_deg, 30.0f, 140.0f);
|
||||
p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
|
||||
p->chroma = clampf(p->chroma, 0.0f, 0.05f);
|
||||
p->mono = p->mono ? 1 : 0;
|
||||
p->display_hz = clampf(p->display_hz, 0.0f, 240.0f);
|
||||
}
|
||||
|
||||
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
|
||||
int src_w, int src_h, int out_w, int out_h, float out[24]) {
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 3; c++) out[r * 4 + c] = pose[r * 3 + c];
|
||||
out[3] = p->mono ? 0.0f : p->ipd_m * 0.5f;
|
||||
out[3] = p->ipd_m * 0.5f;
|
||||
out[7] = p->screen_dist_m;
|
||||
out[11] = p->screen_width_m * 0.5f;
|
||||
out[12] = p->k1;
|
||||
@@ -57,9 +54,8 @@ void cardboard_push_constants(const struct cardboard_params *p, const float pose
|
||||
out[17] = (src_w > 0 && src_h > 0) ? (float)src_w / (float)src_h : 16.0f / 9.0f;
|
||||
if (p->source == CARDBOARD_SOURCE_SBS) out[17] *= 0.5f;
|
||||
out[18] = (float)p->source;
|
||||
out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * (p->mono ? 1.0f : 0.5f)) / (float)out_h : 1.0f;
|
||||
out[20] = p->mono ? 1.0f : 0.0f;
|
||||
out[21] = out[22] = out[23] = 0.0f;
|
||||
out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * 0.5f) / (float)out_h : 1.0f;
|
||||
out[20] = out[21] = out[22] = out[23] = 0.0f;
|
||||
}
|
||||
|
||||
#ifndef CARDBOARD_HOST_TEST
|
||||
@@ -85,21 +81,13 @@ void vkp_cardboard_set(const struct cardboard_params *p) {
|
||||
shm_publish(g_live);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (was != c.enabled || c.enabled)
|
||||
droiddeck_log("cardboard", "%s: %s%s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f",
|
||||
droiddeck_log("cardboard", "%s: %s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f",
|
||||
c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" : "virtual screen",
|
||||
c.mono ? " (one eye)" : "", c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma);
|
||||
c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma);
|
||||
}
|
||||
|
||||
static struct droiddeck_headpose *g_shm;
|
||||
static int g_eye_w, g_eye_h;
|
||||
static int g_render_height;
|
||||
|
||||
void vkp_cardboard_set_render_height(int height) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_render_height = height > 0 ? height : 0;
|
||||
shm_publish(g_live);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
void vkp_cardboard_set_pose_file(const char *path) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
@@ -113,7 +101,6 @@ void vkp_cardboard_set_pose_file(const char *path) {
|
||||
g_shm->magic = DROIDDECK_HEADPOSE_MAGIC;
|
||||
g_shm->version = DROIDDECK_HEADPOSE_VERSION;
|
||||
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
|
||||
shm_publish(g_live);
|
||||
}
|
||||
}
|
||||
if (fd >= 0) close(fd);
|
||||
@@ -132,10 +119,7 @@ static void shm_publish(int live) {
|
||||
atomic_thread_fence(memory_order_release);
|
||||
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
|
||||
g_shm->ipd_m = g_set.ipd_m;
|
||||
g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u) |
|
||||
(g_set.mono ? DROIDDECK_HEADPOSE_MONO : 0u);
|
||||
g_shm->display_hz = g_set.display_hz;
|
||||
g_shm->render_height = (uint32_t)g_render_height;
|
||||
g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u);
|
||||
g_shm->time_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
|
||||
g_shm->tan_half_fov = tanf(g_set.fov_deg * 0.5f * (float)M_PI / 180.0f);
|
||||
g_shm->eye_w = (uint32_t)(g_eye_w > 0 ? g_eye_w : 0);
|
||||
@@ -417,15 +401,14 @@ VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h
|
||||
if (!said) { said = 1; droiddeck_log("error", "cardboard: no descriptor for the frame; shown flat"); }
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
snapshot();
|
||||
int eye_w = g_cur.mono ? out_w : out_w / 2;
|
||||
if (g_eye_w != eye_w || g_eye_h != out_h) {
|
||||
if (g_eye_w != out_w / 2 || g_eye_h != out_h) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_eye_w = eye_w;
|
||||
g_eye_w = out_w / 2;
|
||||
g_eye_h = out_h;
|
||||
shm_publish(g_live);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
snapshot();
|
||||
struct { float ndc[4]; float rest[24]; } pc = {{-1.0f, -1.0f, 1.0f, 1.0f}, {0}};
|
||||
cardboard_push_constants(&g_cur, g_cur_pose, src_w, src_h, out_w, out_h, pc.rest);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ layout(push_constant) uniform PC {
|
||||
vec4 rot2;
|
||||
vec4 lens;
|
||||
vec4 view;
|
||||
vec4 extra;
|
||||
|
||||
} pc;
|
||||
|
||||
const vec3 BACKDROP = vec3(0.015, 0.015, 0.02);
|
||||
@@ -40,10 +40,9 @@ vec3 eyeSample(vec2 tan, float eye) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
bool mono = pc.extra.x > 0.5;
|
||||
float eye = mono || uv.x < 0.5 ? 0.0 : 1.0;
|
||||
vec2 local = mono ? uv : vec2(uv.x * 2.0 - eye, uv.y);
|
||||
float cx = mono ? 0.5 : 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x);
|
||||
float eye = uv.x < 0.5 ? 0.0 : 1.0;
|
||||
vec2 local = vec2(uv.x * 2.0 - eye, uv.y);
|
||||
float cx = 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x);
|
||||
vec2 p = local - vec2(cx, 0.5);
|
||||
float tanH = pc.lens.z;
|
||||
vec2 t = vec2(p.x * pc.view.w, p.y) * 2.0 * tanH;
|
||||
|
||||
@@ -15,14 +15,11 @@ struct cardboard_params {
|
||||
float fov_deg;
|
||||
float lens_inset;
|
||||
float chroma;
|
||||
int mono;
|
||||
float display_hz;
|
||||
};
|
||||
|
||||
void vkp_cardboard_set(const struct cardboard_params *p);
|
||||
void vkp_cardboard_set_pose(const float m[9], int live);
|
||||
void vkp_cardboard_set_pose_file(const char *path);
|
||||
void vkp_cardboard_set_render_height(int height);
|
||||
|
||||
void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops);
|
||||
int vkp_cardboard_active(void);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
const uint32_t cardboard_frag_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x00000185,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000f8,0x0000017d,0x00030010,
|
||||
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010,
|
||||
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
|
||||
0x00000000,0x00070005,0x0000000d,0x69646e75,0x726f7473,0x66762874,0x31663b32,0x0000003b,
|
||||
0x00030005,0x0000000b,0x00000074,0x00040005,0x0000000c,0x6c616373,0x00000065,0x00070005,
|
||||
@@ -11,273 +11,255 @@ const uint32_t cardboard_frag_code[] = {
|
||||
0x0000001e,0x00004350,0x00040006,0x0000001e,0x00000000,0x0063646e,0x00050006,0x0000001e,
|
||||
0x00000001,0x30746f72,0x00000000,0x00050006,0x0000001e,0x00000002,0x31746f72,0x00000000,
|
||||
0x00050006,0x0000001e,0x00000003,0x32746f72,0x00000000,0x00050006,0x0000001e,0x00000004,
|
||||
0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00050006,
|
||||
0x0000001e,0x00000006,0x72747865,0x00000061,0x00030005,0x00000020,0x00006370,0x00040005,
|
||||
0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,0x00000073,0x00040005,0x00000068,
|
||||
0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,0x00030005,0x00000094,0x00000064,
|
||||
0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,0x00000074,0x00030005,0x000000bd,
|
||||
0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,0x00040005,0x000000c6,0x666c6168,
|
||||
0x00000048,0x00030005,0x000000cb,0x00000073,0x00040005,0x000000ed,0x6f6e6f6d,0x00000000,
|
||||
0x00030005,0x000000f2,0x00657965,0x00030005,0x000000f8,0x00007675,0x00040005,0x000000ff,
|
||||
0x61636f6c,0x0000006c,0x00030005,0x00000110,0x00007863,0x00030005,0x00000124,0x00000070,
|
||||
0x00040005,0x00000129,0x486e6174,0x00000000,0x00030005,0x0000012c,0x00000074,0x00030005,
|
||||
0x0000013d,0x00000063,0x00040005,0x00000141,0x61726170,0x0000006d,0x00040005,0x00000143,
|
||||
0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,0x0000006d,0x00040005,0x00000146,
|
||||
0x61726170,0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,
|
||||
0x61726170,0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,
|
||||
0x61726170,0x0000006d,0x00040005,0x00000158,0x61726170,0x0000006d,0x00040005,0x0000015a,
|
||||
0x61726170,0x0000006d,0x00040005,0x0000015c,0x61726170,0x0000006d,0x00040005,0x0000015d,
|
||||
0x61726170,0x0000006d,0x00040005,0x00000163,0x61726170,0x0000006d,0x00040005,0x00000165,
|
||||
0x61726170,0x0000006d,0x00040005,0x00000167,0x61726170,0x0000006d,0x00040005,0x00000168,
|
||||
0x61726170,0x0000006d,0x00030005,0x0000016b,0x00000065,0x00040005,0x00000171,0x65646166,
|
||||
0x00000000,0x00050005,0x0000017d,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,
|
||||
0x00000002,0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,
|
||||
0x00000001,0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,
|
||||
0x00050048,0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,
|
||||
0x00000023,0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00050048,
|
||||
0x0000001e,0x00000006,0x00000023,0x00000060,0x00040047,0x00000068,0x00000021,0x00000000,
|
||||
0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000f8,0x0000001e,0x00000000,
|
||||
0x00040047,0x0000017d,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,
|
||||
0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,
|
||||
0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,0x00000007,0x00000006,
|
||||
0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,0x0000000f,0x00000006,
|
||||
0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,0x0004002b,0x00000006,
|
||||
0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,0x0009001e,0x0000001e,
|
||||
0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x00040020,
|
||||
0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,0x00040015,
|
||||
0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,0x00040015,
|
||||
0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,0x00040020,
|
||||
0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,0x0004002b,
|
||||
0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,0x0004002b,
|
||||
0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,0x00000046,
|
||||
0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,0x00000055,
|
||||
0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,0x00000007,
|
||||
0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,0x00000054,
|
||||
0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
|
||||
0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,0x00000000,
|
||||
0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,0x0000000f,
|
||||
0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,0x00000078,
|
||||
0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,0x0000007d,
|
||||
0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,0x00000007,
|
||||
0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,0x000000ab,
|
||||
0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,0x000000b0,
|
||||
0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,0x00040020,
|
||||
0x000000ec,0x00000007,0x0000003b,0x0004002b,0x00000021,0x000000ee,0x00000006,0x00040020,
|
||||
0x000000f7,0x00000001,0x00000007,0x0004003b,0x000000f7,0x000000f8,0x00000001,0x00040020,
|
||||
0x000000f9,0x00000001,0x00000006,0x0004002b,0x00000006,0x00000108,0x40000000,0x0004002b,
|
||||
0x00000006,0x00000172,0x3cf5c28f,0x00040020,0x0000017c,0x00000003,0x0000001d,0x0004003b,
|
||||
0x0000017c,0x0000017d,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,
|
||||
0x000200f8,0x00000005,0x0004003b,0x000000ec,0x000000ed,0x00000007,0x0004003b,0x00000009,
|
||||
0x000000f2,0x00000007,0x0004003b,0x00000008,0x000000ff,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000101,0x00000007,0x0004003b,0x00000009,0x00000110,0x00000007,0x0004003b,0x00000009,
|
||||
0x00000112,0x00000007,0x0004003b,0x00000009,0x00000118,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000124,0x00000007,0x0004003b,0x00000009,0x00000129,0x00000007,0x0004003b,0x00000008,
|
||||
0x0000012c,0x00000007,0x0004003b,0x00000093,0x0000013d,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000141,0x00000007,0x0004003b,0x00000009,0x00000143,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000145,0x00000007,0x0004003b,0x00000009,0x00000146,0x00000007,0x0004003b,0x00000008,
|
||||
0x0000014b,0x00000007,0x0004003b,0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,
|
||||
0x0000014f,0x00000007,0x0004003b,0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000158,0x00000007,0x0004003b,0x00000009,0x0000015a,0x00000007,0x0004003b,0x00000008,
|
||||
0x0000015c,0x00000007,0x0004003b,0x00000009,0x0000015d,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000163,0x00000007,0x0004003b,0x00000009,0x00000165,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000167,0x00000007,0x0004003b,0x00000009,0x00000168,0x00000007,0x0004003b,0x00000008,
|
||||
0x0000016b,0x00000007,0x0004003b,0x00000009,0x00000171,0x00000007,0x00060041,0x00000025,
|
||||
0x000000ef,0x00000020,0x000000ee,0x00000024,0x0004003d,0x00000006,0x000000f0,0x000000ef,
|
||||
0x000500ba,0x0000003b,0x000000f1,0x000000f0,0x0000003a,0x0003003e,0x000000ed,0x000000f1,
|
||||
0x0004003d,0x0000003b,0x000000f3,0x000000ed,0x000400a8,0x0000003b,0x000000f4,0x000000f3,
|
||||
0x000300f7,0x000000f6,0x00000000,0x000400fa,0x000000f4,0x000000f5,0x000000f6,0x000200f8,
|
||||
0x000000f5,0x00050041,0x000000f9,0x000000fa,0x000000f8,0x00000024,0x0004003d,0x00000006,
|
||||
0x000000fb,0x000000fa,0x000500b8,0x0000003b,0x000000fc,0x000000fb,0x0000003a,0x000200f9,
|
||||
0x000000f6,0x000200f8,0x000000f6,0x000700f5,0x0000003b,0x000000fd,0x000000f3,0x00000005,
|
||||
0x000000fc,0x000000f5,0x000600a9,0x00000006,0x000000fe,0x000000fd,0x00000054,0x0000001c,
|
||||
0x0003003e,0x000000f2,0x000000fe,0x0004003d,0x0000003b,0x00000100,0x000000ed,0x000300f7,
|
||||
0x00000103,0x00000000,0x000400fa,0x00000100,0x00000102,0x00000105,0x000200f8,0x00000102,
|
||||
0x0004003d,0x00000007,0x00000104,0x000000f8,0x0003003e,0x00000101,0x00000104,0x000200f9,
|
||||
0x00000103,0x000200f8,0x00000105,0x00050041,0x000000f9,0x00000106,0x000000f8,0x00000024,
|
||||
0x0004003d,0x00000006,0x00000107,0x00000106,0x00050085,0x00000006,0x00000109,0x00000107,
|
||||
0x00000108,0x0004003d,0x00000006,0x0000010a,0x000000f2,0x00050083,0x00000006,0x0000010b,
|
||||
0x00000109,0x0000010a,0x00050041,0x000000f9,0x0000010c,0x000000f8,0x0000002b,0x0004003d,
|
||||
0x00000006,0x0000010d,0x0000010c,0x00050050,0x00000007,0x0000010e,0x0000010b,0x0000010d,
|
||||
0x0003003e,0x00000101,0x0000010e,0x000200f9,0x00000103,0x000200f8,0x00000103,0x0004003d,
|
||||
0x00000007,0x0000010f,0x00000101,0x0003003e,0x000000ff,0x0000010f,0x0004003d,0x0000003b,
|
||||
0x00000111,0x000000ed,0x000300f7,0x00000114,0x00000000,0x000400fa,0x00000111,0x00000113,
|
||||
0x00000115,0x000200f8,0x00000113,0x0003003e,0x00000112,0x0000003a,0x000200f9,0x00000114,
|
||||
0x000200f8,0x00000115,0x0004003d,0x00000006,0x00000116,0x000000f2,0x000500b8,0x0000003b,
|
||||
0x00000117,0x00000116,0x0000003a,0x000300f7,0x0000011a,0x00000000,0x000400fa,0x00000117,
|
||||
0x00000119,0x0000011d,0x000200f8,0x00000119,0x00060041,0x00000025,0x0000011b,0x00000020,
|
||||
0x00000036,0x00000024,0x0004003d,0x00000006,0x0000011c,0x0000011b,0x0003003e,0x00000118,
|
||||
0x0000011c,0x000200f9,0x0000011a,0x000200f8,0x0000011d,0x00060041,0x00000025,0x0000011e,
|
||||
0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x0000011f,0x0000011e,0x0004007f,
|
||||
0x00000006,0x00000120,0x0000011f,0x0003003e,0x00000118,0x00000120,0x000200f9,0x0000011a,
|
||||
0x000200f8,0x0000011a,0x0004003d,0x00000006,0x00000121,0x00000118,0x00050081,0x00000006,
|
||||
0x00000122,0x0000003a,0x00000121,0x0003003e,0x00000112,0x00000122,0x000200f9,0x00000114,
|
||||
0x000200f8,0x00000114,0x0004003d,0x00000006,0x00000123,0x00000112,0x0003003e,0x00000110,
|
||||
0x00000123,0x0004003d,0x00000007,0x00000125,0x000000ff,0x0004003d,0x00000006,0x00000126,
|
||||
0x00000110,0x00050050,0x00000007,0x00000127,0x00000126,0x0000003a,0x00050083,0x00000007,
|
||||
0x00000128,0x00000125,0x00000127,0x0003003e,0x00000124,0x00000128,0x00060041,0x00000025,
|
||||
0x0000012a,0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x0000012b,0x0000012a,
|
||||
0x0003003e,0x00000129,0x0000012b,0x00050041,0x00000009,0x0000012d,0x00000124,0x00000024,
|
||||
0x0004003d,0x00000006,0x0000012e,0x0000012d,0x00060041,0x00000025,0x0000012f,0x00000020,
|
||||
0x00000036,0x00000046,0x0004003d,0x00000006,0x00000130,0x0000012f,0x00050085,0x00000006,
|
||||
0x00000131,0x0000012e,0x00000130,0x00050041,0x00000009,0x00000132,0x00000124,0x0000002b,
|
||||
0x0004003d,0x00000006,0x00000133,0x00000132,0x00050050,0x00000007,0x00000134,0x00000131,
|
||||
0x00000133,0x0005008e,0x00000007,0x00000135,0x00000134,0x00000108,0x0004003d,0x00000006,
|
||||
0x00000136,0x00000129,0x0005008e,0x00000007,0x00000137,0x00000135,0x00000136,0x0003003e,
|
||||
0x0000012c,0x00000137,0x00060041,0x00000025,0x00000138,0x00000020,0x00000022,0x00000046,
|
||||
0x0004003d,0x00000006,0x00000139,0x00000138,0x000500ba,0x0000003b,0x0000013a,0x00000139,
|
||||
0x00000054,0x000300f7,0x0000013c,0x00000000,0x000400fa,0x0000013a,0x0000013b,0x00000162,
|
||||
0x000200f8,0x0000013b,0x00060041,0x00000025,0x0000013e,0x00000020,0x00000022,0x00000046,
|
||||
0x0004003d,0x00000006,0x0000013f,0x0000013e,0x00050083,0x00000006,0x00000140,0x0000001c,
|
||||
0x0000013f,0x0004003d,0x00000007,0x00000142,0x0000012c,0x0003003e,0x00000141,0x00000142,
|
||||
0x0003003e,0x00000143,0x00000140,0x00060039,0x00000007,0x00000144,0x0000000d,0x00000141,
|
||||
0x00000143,0x0003003e,0x00000145,0x00000144,0x0004003d,0x00000006,0x00000147,0x000000f2,
|
||||
0x0003003e,0x00000146,0x00000147,0x00060039,0x0000000f,0x00000148,0x00000013,0x00000145,
|
||||
0x00000146,0x00050051,0x00000006,0x00000149,0x00000148,0x00000000,0x00050041,0x00000009,
|
||||
0x0000014a,0x0000013d,0x00000024,0x0003003e,0x0000014a,0x00000149,0x0004003d,0x00000007,
|
||||
0x0000014c,0x0000012c,0x0003003e,0x0000014b,0x0000014c,0x0003003e,0x0000014d,0x0000001c,
|
||||
0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,0x0003003e,0x0000014f,
|
||||
0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000f2,0x0003003e,0x00000150,0x00000151,
|
||||
0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,0x00050051,0x00000006,
|
||||
0x00000153,0x00000152,0x00000001,0x00050041,0x00000009,0x00000154,0x0000013d,0x0000002b,
|
||||
0x0003003e,0x00000154,0x00000153,0x00060041,0x00000025,0x00000155,0x00000020,0x00000022,
|
||||
0x00000046,0x0004003d,0x00000006,0x00000156,0x00000155,0x00050081,0x00000006,0x00000157,
|
||||
0x0000001c,0x00000156,0x0004003d,0x00000007,0x00000159,0x0000012c,0x0003003e,0x00000158,
|
||||
0x00000159,0x0003003e,0x0000015a,0x00000157,0x00060039,0x00000007,0x0000015b,0x0000000d,
|
||||
0x00000158,0x0000015a,0x0003003e,0x0000015c,0x0000015b,0x0004003d,0x00000006,0x0000015e,
|
||||
0x000000f2,0x0003003e,0x0000015d,0x0000015e,0x00060039,0x0000000f,0x0000015f,0x00000013,
|
||||
0x0000015c,0x0000015d,0x00050051,0x00000006,0x00000160,0x0000015f,0x00000002,0x00050041,
|
||||
0x00000009,0x00000161,0x0000013d,0x00000037,0x0003003e,0x00000161,0x00000160,0x000200f9,
|
||||
0x0000013c,0x000200f8,0x00000162,0x0004003d,0x00000007,0x00000164,0x0000012c,0x0003003e,
|
||||
0x00000163,0x00000164,0x0003003e,0x00000165,0x0000001c,0x00060039,0x00000007,0x00000166,
|
||||
0x0000000d,0x00000163,0x00000165,0x0003003e,0x00000167,0x00000166,0x0004003d,0x00000006,
|
||||
0x00000169,0x000000f2,0x0003003e,0x00000168,0x00000169,0x00060039,0x0000000f,0x0000016a,
|
||||
0x00000013,0x00000167,0x00000168,0x0003003e,0x0000013d,0x0000016a,0x000200f9,0x0000013c,
|
||||
0x000200f8,0x0000013c,0x0004003d,0x00000007,0x0000016c,0x000000ff,0x0004003d,0x00000007,
|
||||
0x0000016d,0x000000ff,0x00050050,0x00000007,0x0000016e,0x0000001c,0x0000001c,0x00050083,
|
||||
0x00000007,0x0000016f,0x0000016e,0x0000016d,0x0007000c,0x00000007,0x00000170,0x00000001,
|
||||
0x00000025,0x0000016c,0x0000016f,0x0003003e,0x0000016b,0x00000170,0x00050041,0x00000009,
|
||||
0x00000173,0x0000016b,0x00000024,0x0004003d,0x00000006,0x00000174,0x00000173,0x00060041,
|
||||
0x00000025,0x00000175,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x00000176,
|
||||
0x00000175,0x00050085,0x00000006,0x00000177,0x00000174,0x00000176,0x00050041,0x00000009,
|
||||
0x00000178,0x0000016b,0x0000002b,0x0004003d,0x00000006,0x00000179,0x00000178,0x0007000c,
|
||||
0x00000006,0x0000017a,0x00000001,0x00000025,0x00000177,0x00000179,0x0008000c,0x00000006,
|
||||
0x0000017b,0x00000001,0x00000031,0x00000054,0x00000172,0x0000017a,0x0003003e,0x00000171,
|
||||
0x0000017b,0x0004003d,0x0000000f,0x0000017e,0x0000013d,0x0004003d,0x00000006,0x0000017f,
|
||||
0x00000171,0x0005008e,0x0000000f,0x00000180,0x0000017e,0x0000017f,0x00050051,0x00000006,
|
||||
0x00000181,0x00000180,0x00000000,0x00050051,0x00000006,0x00000182,0x00000180,0x00000001,
|
||||
0x00050051,0x00000006,0x00000183,0x00000180,0x00000002,0x00070050,0x0000001d,0x00000184,
|
||||
0x00000181,0x00000182,0x00000183,0x0000001c,0x0003003e,0x0000017d,0x00000184,0x000100fd,
|
||||
0x00010038,0x00050036,0x00000007,0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,
|
||||
0x0000000b,0x00030037,0x00000009,0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,
|
||||
0x00000015,0x00000007,0x0004003d,0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,
|
||||
0x00000017,0x0000000b,0x00050094,0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,
|
||||
0x00000015,0x00000018,0x0004003d,0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,
|
||||
0x0000001a,0x0000000c,0x0005008e,0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,
|
||||
0x00000025,0x00000026,0x00000020,0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,
|
||||
0x00000026,0x0004003d,0x00000006,0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,
|
||||
0x00000027,0x00000028,0x00050081,0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,
|
||||
0x00000025,0x0000002c,0x00000020,0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,
|
||||
0x0000002c,0x0004003d,0x00000006,0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,
|
||||
0x0000002d,0x0000002e,0x0004003d,0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,
|
||||
0x00000031,0x0000002f,0x00000030,0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,
|
||||
0x0005008e,0x00000007,0x00000033,0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,
|
||||
0x00050036,0x0000000f,0x00000013,0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,
|
||||
0x00030037,0x00000009,0x00000012,0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,
|
||||
0x00000007,0x0004003b,0x00000008,0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,
|
||||
0x00000007,0x0004003b,0x00000093,0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,
|
||||
0x00000007,0x0004003b,0x00000009,0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,
|
||||
0x00000007,0x0004003b,0x00000009,0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,
|
||||
0x00000007,0x0004003b,0x00000008,0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,
|
||||
0x00000020,0x00000036,0x00000037,0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,
|
||||
0x0000003b,0x0000003c,0x00000039,0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,
|
||||
0x0000003c,0x0000003d,0x0000003e,0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,
|
||||
0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,
|
||||
0x0000003f,0x00000041,0x00050041,0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,
|
||||
0x00000006,0x00000044,0x00000043,0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,
|
||||
0x00000025,0x00000047,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,
|
||||
0x00000047,0x00050085,0x00000006,0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,
|
||||
0x0000004a,0x00000044,0x00000049,0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,
|
||||
0x0004003d,0x00000006,0x0000004c,0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,
|
||||
0x00050088,0x00000006,0x0000004e,0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,
|
||||
0x0000004a,0x0000004e,0x0005008e,0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,
|
||||
0x00000007,0x00000051,0x0000003a,0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,
|
||||
0x00000051,0x0003003e,0x00000042,0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,
|
||||
0x000500b8,0x00000056,0x00000057,0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,
|
||||
0x00000057,0x000400a8,0x0000003b,0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,
|
||||
0x000400fa,0x00000059,0x0000005a,0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,
|
||||
0x0000005c,0x00000042,0x000500ba,0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,
|
||||
0x0000003b,0x0000005f,0x0000005e,0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,
|
||||
0x0000003b,0x00000060,0x00000058,0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,
|
||||
0x00000000,0x000400fa,0x00000060,0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,
|
||||
0x00000063,0x000200f8,0x00000062,0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,
|
||||
0x00000009,0x0000006a,0x00000042,0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,
|
||||
0x0004003d,0x00000006,0x0000006c,0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,
|
||||
0x0000006c,0x00050085,0x00000006,0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,
|
||||
0x0000006f,0x00000042,0x0000002b,0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,
|
||||
0x00000007,0x00000071,0x0000006e,0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,
|
||||
0x00000071,0x0008004f,0x0000000f,0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,
|
||||
0x00000002,0x000200fe,0x00000073,0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,
|
||||
0x00000020,0x00000078,0x0004003d,0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,
|
||||
0x0000007c,0x0000007b,0x0000007b,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,
|
||||
0x0000007e,0x00000020,0x0000007d,0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,
|
||||
0x0000000f,0x00000080,0x0000007f,0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,
|
||||
0x00000079,0x00000082,0x00000020,0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,
|
||||
0x0008004f,0x0000000f,0x00000084,0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,
|
||||
0x00050051,0x00000006,0x00000085,0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,
|
||||
0x0000007c,0x00000001,0x00050051,0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,
|
||||
0x00000006,0x00000088,0x00000080,0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,
|
||||
0x00000001,0x00050051,0x00000006,0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,
|
||||
0x0000008b,0x00000084,0x00000000,0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,
|
||||
0x00050051,0x00000006,0x0000008d,0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,
|
||||
0x00000085,0x00000086,0x00000087,0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,
|
||||
0x0000008a,0x00060050,0x0000000f,0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,
|
||||
0x00000075,0x00000091,0x0000008e,0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,
|
||||
0x00000091,0x0003003e,0x00000077,0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,
|
||||
0x00050041,0x00000009,0x00000096,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,
|
||||
0x00000096,0x00050041,0x00000009,0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,
|
||||
0x00000099,0x00000098,0x0004007f,0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,
|
||||
0x0000009c,0x00000097,0x0000009a,0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,
|
||||
0x00000045,0x0000009c,0x00050091,0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,
|
||||
0x00000094,0x0000009e,0x0004003d,0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,
|
||||
0x000000a1,0x00000012,0x000500b8,0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,
|
||||
0x00000006,0x000000a3,0x000000a2,0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,
|
||||
0x00000020,0x00000078,0x00000046,0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,
|
||||
0x00000006,0x000000a6,0x000000a3,0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,
|
||||
0x00000054,0x00000054,0x00050091,0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,
|
||||
0x0000009f,0x000000a8,0x00050041,0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,
|
||||
0x00000006,0x000000aa,0x000000a9,0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,
|
||||
0x000300f7,0x000000ae,0x00000000,0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,
|
||||
0x000000ad,0x000200fe,0x000000b1,0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,
|
||||
0x00000020,0x0000007d,0x00000046,0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,
|
||||
0x00000006,0x000000b6,0x000000b5,0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,
|
||||
0x0004003d,0x00000006,0x000000b8,0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,
|
||||
0x000000b8,0x00050041,0x00000009,0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,
|
||||
0x000000bb,0x000000ba,0x00050088,0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,
|
||||
0x000000b3,0x000000bc,0x0004003d,0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,
|
||||
0x000000bf,0x00000094,0x0004003d,0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,
|
||||
0x000000c1,0x000000bf,0x000000c0,0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,
|
||||
0x0003003e,0x000000bd,0x000000c2,0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,
|
||||
0x00000046,0x0004003d,0x00000006,0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,
|
||||
0x0004003d,0x00000006,0x000000c7,0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,
|
||||
0x00000036,0x0000002b,0x0004003d,0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,
|
||||
0x000000ca,0x000000c7,0x000000c9,0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,
|
||||
0x000000cc,0x000000bd,0x00000024,0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,
|
||||
0x00000006,0x000000ce,0x000000c3,0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,
|
||||
0x00050041,0x00000009,0x000000d0,0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,
|
||||
0x000000d0,0x0004007f,0x00000006,0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,
|
||||
0x000000c6,0x00050088,0x00000006,0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,
|
||||
0x000000d5,0x000000cf,0x000000d4,0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,
|
||||
0x00050050,0x00000007,0x000000d7,0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,
|
||||
0x000000d6,0x000000d7,0x0003003e,0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,
|
||||
0x000000cb,0x000500b8,0x00000056,0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,
|
||||
0x000000db,0x000000da,0x000400a8,0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,
|
||||
0x00000000,0x000400fa,0x000000dc,0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,
|
||||
0x00000007,0x000000df,0x000000cb,0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,
|
||||
0x0004009a,0x0000003b,0x000000e1,0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,
|
||||
0x000700f5,0x0000003b,0x000000e2,0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,
|
||||
0x000000e4,0x00000000,0x000400fa,0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,
|
||||
0x000200fe,0x000000b1,0x000200f8,0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,
|
||||
0x0004003d,0x00000007,0x000000e7,0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,
|
||||
0x000000e7,0x0008004f,0x0000000f,0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,
|
||||
0x00000002,0x000200fe,0x000000e9,0x00010038
|
||||
0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00030005,
|
||||
0x00000020,0x00006370,0x00040005,0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,
|
||||
0x00000073,0x00040005,0x00000068,0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,
|
||||
0x00030005,0x00000094,0x00000064,0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,
|
||||
0x00000074,0x00030005,0x000000bd,0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,
|
||||
0x00040005,0x000000c6,0x666c6168,0x00000048,0x00030005,0x000000cb,0x00000073,0x00030005,
|
||||
0x000000ec,0x00657965,0x00030005,0x000000ee,0x00007675,0x00040005,0x000000f4,0x61636f6c,
|
||||
0x0000006c,0x00030005,0x000000fe,0x00007863,0x00030005,0x0000010c,0x00000070,0x00040005,
|
||||
0x00000111,0x486e6174,0x00000000,0x00030005,0x00000114,0x00000074,0x00030005,0x00000125,
|
||||
0x00000063,0x00040005,0x00000129,0x61726170,0x0000006d,0x00040005,0x0000012b,0x61726170,
|
||||
0x0000006d,0x00040005,0x0000012d,0x61726170,0x0000006d,0x00040005,0x0000012e,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000133,0x61726170,0x0000006d,0x00040005,0x00000135,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000137,0x61726170,0x0000006d,0x00040005,0x00000138,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000140,0x61726170,0x0000006d,0x00040005,0x00000142,0x61726170,
|
||||
0x0000006d,0x00040005,0x00000144,0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,
|
||||
0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,0x61726170,
|
||||
0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,0x61726170,
|
||||
0x0000006d,0x00030005,0x00000153,0x00000065,0x00040005,0x00000159,0x65646166,0x00000000,
|
||||
0x00050005,0x00000165,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,0x00000002,
|
||||
0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,0x00000001,
|
||||
0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,0x00050048,
|
||||
0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,0x00000023,
|
||||
0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00040047,0x00000068,
|
||||
0x00000021,0x00000000,0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000ee,
|
||||
0x0000001e,0x00000000,0x00040047,0x00000165,0x0000001e,0x00000000,0x00020013,0x00000002,
|
||||
0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,
|
||||
0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,
|
||||
0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,
|
||||
0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,
|
||||
0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,
|
||||
0x0008001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,
|
||||
0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,
|
||||
0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,
|
||||
0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,
|
||||
0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,
|
||||
0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,
|
||||
0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,
|
||||
0x00000046,0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,
|
||||
0x00000055,0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,
|
||||
0x00000007,0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,
|
||||
0x00000054,0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,
|
||||
0x00000000,0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,
|
||||
0x00000000,0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,
|
||||
0x0000000f,0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,
|
||||
0x00000078,0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,
|
||||
0x0000007d,0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,
|
||||
0x00000007,0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,
|
||||
0x000000ab,0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,
|
||||
0x000000b0,0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,
|
||||
0x00040020,0x000000ed,0x00000001,0x00000007,0x0004003b,0x000000ed,0x000000ee,0x00000001,
|
||||
0x00040020,0x000000ef,0x00000001,0x00000006,0x0004002b,0x00000006,0x000000f7,0x40000000,
|
||||
0x0004002b,0x00000006,0x0000015a,0x3cf5c28f,0x00040020,0x00000164,0x00000003,0x0000001d,
|
||||
0x0004003b,0x00000164,0x00000165,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,
|
||||
0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000009,0x000000ec,0x00000007,0x0004003b,
|
||||
0x00000008,0x000000f4,0x00000007,0x0004003b,0x00000009,0x000000fe,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000101,0x00000007,0x0004003b,0x00000008,0x0000010c,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000111,0x00000007,0x0004003b,0x00000008,0x00000114,0x00000007,0x0004003b,
|
||||
0x00000093,0x00000125,0x00000007,0x0004003b,0x00000008,0x00000129,0x00000007,0x0004003b,
|
||||
0x00000009,0x0000012b,0x00000007,0x0004003b,0x00000008,0x0000012d,0x00000007,0x0004003b,
|
||||
0x00000009,0x0000012e,0x00000007,0x0004003b,0x00000008,0x00000133,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000135,0x00000007,0x0004003b,0x00000008,0x00000137,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000138,0x00000007,0x0004003b,0x00000008,0x00000140,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000142,0x00000007,0x0004003b,0x00000008,0x00000144,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000145,0x00000007,0x0004003b,0x00000008,0x0000014b,0x00000007,0x0004003b,
|
||||
0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x0000014f,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,0x00000153,0x00000007,0x0004003b,
|
||||
0x00000009,0x00000159,0x00000007,0x00050041,0x000000ef,0x000000f0,0x000000ee,0x00000024,
|
||||
0x0004003d,0x00000006,0x000000f1,0x000000f0,0x000500b8,0x0000003b,0x000000f2,0x000000f1,
|
||||
0x0000003a,0x000600a9,0x00000006,0x000000f3,0x000000f2,0x00000054,0x0000001c,0x0003003e,
|
||||
0x000000ec,0x000000f3,0x00050041,0x000000ef,0x000000f5,0x000000ee,0x00000024,0x0004003d,
|
||||
0x00000006,0x000000f6,0x000000f5,0x00050085,0x00000006,0x000000f8,0x000000f6,0x000000f7,
|
||||
0x0004003d,0x00000006,0x000000f9,0x000000ec,0x00050083,0x00000006,0x000000fa,0x000000f8,
|
||||
0x000000f9,0x00050041,0x000000ef,0x000000fb,0x000000ee,0x0000002b,0x0004003d,0x00000006,
|
||||
0x000000fc,0x000000fb,0x00050050,0x00000007,0x000000fd,0x000000fa,0x000000fc,0x0003003e,
|
||||
0x000000f4,0x000000fd,0x0004003d,0x00000006,0x000000ff,0x000000ec,0x000500b8,0x0000003b,
|
||||
0x00000100,0x000000ff,0x0000003a,0x000300f7,0x00000103,0x00000000,0x000400fa,0x00000100,
|
||||
0x00000102,0x00000106,0x000200f8,0x00000102,0x00060041,0x00000025,0x00000104,0x00000020,
|
||||
0x00000036,0x00000024,0x0004003d,0x00000006,0x00000105,0x00000104,0x0003003e,0x00000101,
|
||||
0x00000105,0x000200f9,0x00000103,0x000200f8,0x00000106,0x00060041,0x00000025,0x00000107,
|
||||
0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x00000108,0x00000107,0x0004007f,
|
||||
0x00000006,0x00000109,0x00000108,0x0003003e,0x00000101,0x00000109,0x000200f9,0x00000103,
|
||||
0x000200f8,0x00000103,0x0004003d,0x00000006,0x0000010a,0x00000101,0x00050081,0x00000006,
|
||||
0x0000010b,0x0000003a,0x0000010a,0x0003003e,0x000000fe,0x0000010b,0x0004003d,0x00000007,
|
||||
0x0000010d,0x000000f4,0x0004003d,0x00000006,0x0000010e,0x000000fe,0x00050050,0x00000007,
|
||||
0x0000010f,0x0000010e,0x0000003a,0x00050083,0x00000007,0x00000110,0x0000010d,0x0000010f,
|
||||
0x0003003e,0x0000010c,0x00000110,0x00060041,0x00000025,0x00000112,0x00000020,0x00000022,
|
||||
0x00000037,0x0004003d,0x00000006,0x00000113,0x00000112,0x0003003e,0x00000111,0x00000113,
|
||||
0x00050041,0x00000009,0x00000115,0x0000010c,0x00000024,0x0004003d,0x00000006,0x00000116,
|
||||
0x00000115,0x00060041,0x00000025,0x00000117,0x00000020,0x00000036,0x00000046,0x0004003d,
|
||||
0x00000006,0x00000118,0x00000117,0x00050085,0x00000006,0x00000119,0x00000116,0x00000118,
|
||||
0x00050041,0x00000009,0x0000011a,0x0000010c,0x0000002b,0x0004003d,0x00000006,0x0000011b,
|
||||
0x0000011a,0x00050050,0x00000007,0x0000011c,0x00000119,0x0000011b,0x0005008e,0x00000007,
|
||||
0x0000011d,0x0000011c,0x000000f7,0x0004003d,0x00000006,0x0000011e,0x00000111,0x0005008e,
|
||||
0x00000007,0x0000011f,0x0000011d,0x0000011e,0x0003003e,0x00000114,0x0000011f,0x00060041,
|
||||
0x00000025,0x00000120,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000121,
|
||||
0x00000120,0x000500ba,0x0000003b,0x00000122,0x00000121,0x00000054,0x000300f7,0x00000124,
|
||||
0x00000000,0x000400fa,0x00000122,0x00000123,0x0000014a,0x000200f8,0x00000123,0x00060041,
|
||||
0x00000025,0x00000126,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000127,
|
||||
0x00000126,0x00050083,0x00000006,0x00000128,0x0000001c,0x00000127,0x0004003d,0x00000007,
|
||||
0x0000012a,0x00000114,0x0003003e,0x00000129,0x0000012a,0x0003003e,0x0000012b,0x00000128,
|
||||
0x00060039,0x00000007,0x0000012c,0x0000000d,0x00000129,0x0000012b,0x0003003e,0x0000012d,
|
||||
0x0000012c,0x0004003d,0x00000006,0x0000012f,0x000000ec,0x0003003e,0x0000012e,0x0000012f,
|
||||
0x00060039,0x0000000f,0x00000130,0x00000013,0x0000012d,0x0000012e,0x00050051,0x00000006,
|
||||
0x00000131,0x00000130,0x00000000,0x00050041,0x00000009,0x00000132,0x00000125,0x00000024,
|
||||
0x0003003e,0x00000132,0x00000131,0x0004003d,0x00000007,0x00000134,0x00000114,0x0003003e,
|
||||
0x00000133,0x00000134,0x0003003e,0x00000135,0x0000001c,0x00060039,0x00000007,0x00000136,
|
||||
0x0000000d,0x00000133,0x00000135,0x0003003e,0x00000137,0x00000136,0x0004003d,0x00000006,
|
||||
0x00000139,0x000000ec,0x0003003e,0x00000138,0x00000139,0x00060039,0x0000000f,0x0000013a,
|
||||
0x00000013,0x00000137,0x00000138,0x00050051,0x00000006,0x0000013b,0x0000013a,0x00000001,
|
||||
0x00050041,0x00000009,0x0000013c,0x00000125,0x0000002b,0x0003003e,0x0000013c,0x0000013b,
|
||||
0x00060041,0x00000025,0x0000013d,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,
|
||||
0x0000013e,0x0000013d,0x00050081,0x00000006,0x0000013f,0x0000001c,0x0000013e,0x0004003d,
|
||||
0x00000007,0x00000141,0x00000114,0x0003003e,0x00000140,0x00000141,0x0003003e,0x00000142,
|
||||
0x0000013f,0x00060039,0x00000007,0x00000143,0x0000000d,0x00000140,0x00000142,0x0003003e,
|
||||
0x00000144,0x00000143,0x0004003d,0x00000006,0x00000146,0x000000ec,0x0003003e,0x00000145,
|
||||
0x00000146,0x00060039,0x0000000f,0x00000147,0x00000013,0x00000144,0x00000145,0x00050051,
|
||||
0x00000006,0x00000148,0x00000147,0x00000002,0x00050041,0x00000009,0x00000149,0x00000125,
|
||||
0x00000037,0x0003003e,0x00000149,0x00000148,0x000200f9,0x00000124,0x000200f8,0x0000014a,
|
||||
0x0004003d,0x00000007,0x0000014c,0x00000114,0x0003003e,0x0000014b,0x0000014c,0x0003003e,
|
||||
0x0000014d,0x0000001c,0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,
|
||||
0x0003003e,0x0000014f,0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000ec,0x0003003e,
|
||||
0x00000150,0x00000151,0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,
|
||||
0x0003003e,0x00000125,0x00000152,0x000200f9,0x00000124,0x000200f8,0x00000124,0x0004003d,
|
||||
0x00000007,0x00000154,0x000000f4,0x0004003d,0x00000007,0x00000155,0x000000f4,0x00050050,
|
||||
0x00000007,0x00000156,0x0000001c,0x0000001c,0x00050083,0x00000007,0x00000157,0x00000156,
|
||||
0x00000155,0x0007000c,0x00000007,0x00000158,0x00000001,0x00000025,0x00000154,0x00000157,
|
||||
0x0003003e,0x00000153,0x00000158,0x00050041,0x00000009,0x0000015b,0x00000153,0x00000024,
|
||||
0x0004003d,0x00000006,0x0000015c,0x0000015b,0x00060041,0x00000025,0x0000015d,0x00000020,
|
||||
0x00000036,0x00000046,0x0004003d,0x00000006,0x0000015e,0x0000015d,0x00050085,0x00000006,
|
||||
0x0000015f,0x0000015c,0x0000015e,0x00050041,0x00000009,0x00000160,0x00000153,0x0000002b,
|
||||
0x0004003d,0x00000006,0x00000161,0x00000160,0x0007000c,0x00000006,0x00000162,0x00000001,
|
||||
0x00000025,0x0000015f,0x00000161,0x0008000c,0x00000006,0x00000163,0x00000001,0x00000031,
|
||||
0x00000054,0x0000015a,0x00000162,0x0003003e,0x00000159,0x00000163,0x0004003d,0x0000000f,
|
||||
0x00000166,0x00000125,0x0004003d,0x00000006,0x00000167,0x00000159,0x0005008e,0x0000000f,
|
||||
0x00000168,0x00000166,0x00000167,0x00050051,0x00000006,0x00000169,0x00000168,0x00000000,
|
||||
0x00050051,0x00000006,0x0000016a,0x00000168,0x00000001,0x00050051,0x00000006,0x0000016b,
|
||||
0x00000168,0x00000002,0x00070050,0x0000001d,0x0000016c,0x00000169,0x0000016a,0x0000016b,
|
||||
0x0000001c,0x0003003e,0x00000165,0x0000016c,0x000100fd,0x00010038,0x00050036,0x00000007,
|
||||
0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,0x00030037,0x00000009,
|
||||
0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,0x00000007,0x0004003d,
|
||||
0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,0x0000000b,0x00050094,
|
||||
0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,0x00000018,0x0004003d,
|
||||
0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,0x0000000c,0x0005008e,
|
||||
0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,0x00000026,0x00000020,
|
||||
0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,0x0004003d,0x00000006,
|
||||
0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,0x00000028,0x00050081,
|
||||
0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,0x0000002c,0x00000020,
|
||||
0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,0x0004003d,0x00000006,
|
||||
0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,0x0000002e,0x0004003d,
|
||||
0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,0x0000002f,0x00000030,
|
||||
0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,0x00000007,0x00000033,
|
||||
0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,0x0000000f,0x00000013,
|
||||
0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,0x00000009,0x00000012,
|
||||
0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,0x0004003b,0x00000008,
|
||||
0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,0x00000007,0x0004003b,0x00000093,
|
||||
0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,0x00000007,0x0004003b,0x00000009,
|
||||
0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,0x00000007,0x0004003b,0x00000009,
|
||||
0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,0x00000007,0x0004003b,0x00000008,
|
||||
0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037,
|
||||
0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039,
|
||||
0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e,
|
||||
0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037,
|
||||
0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041,
|
||||
0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043,
|
||||
0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020,
|
||||
0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006,
|
||||
0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049,
|
||||
0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c,
|
||||
0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e,
|
||||
0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e,
|
||||
0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a,
|
||||
0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042,
|
||||
0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,0x000500b8,0x00000056,0x00000057,
|
||||
0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,0x00000057,0x000400a8,0x0000003b,
|
||||
0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,0x000400fa,0x00000059,0x0000005a,
|
||||
0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,0x0000005c,0x00000042,0x000500ba,
|
||||
0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,0x0000003b,0x0000005f,0x0000005e,
|
||||
0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,0x0000003b,0x00000060,0x00000058,
|
||||
0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,0x00000000,0x000400fa,0x00000060,
|
||||
0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,0x00000063,0x000200f8,0x00000062,
|
||||
0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,0x00000009,0x0000006a,0x00000042,
|
||||
0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,0x0004003d,0x00000006,0x0000006c,
|
||||
0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,0x0000006c,0x00050085,0x00000006,
|
||||
0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,0x0000006f,0x00000042,0x0000002b,
|
||||
0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,0x00000007,0x00000071,0x0000006e,
|
||||
0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,0x00000071,0x0008004f,0x0000000f,
|
||||
0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,0x00000002,0x000200fe,0x00000073,
|
||||
0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,0x00000020,0x00000078,0x0004003d,
|
||||
0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,0x0000007c,0x0000007b,0x0000007b,
|
||||
0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x0000007e,0x00000020,0x0000007d,
|
||||
0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,0x0000000f,0x00000080,0x0000007f,
|
||||
0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x00000082,0x00000020,
|
||||
0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,0x0008004f,0x0000000f,0x00000084,
|
||||
0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,0x00050051,0x00000006,0x00000085,
|
||||
0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,0x0000007c,0x00000001,0x00050051,
|
||||
0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,0x00000006,0x00000088,0x00000080,
|
||||
0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,0x00000001,0x00050051,0x00000006,
|
||||
0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,0x0000008b,0x00000084,0x00000000,
|
||||
0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,0x00050051,0x00000006,0x0000008d,
|
||||
0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,0x00000085,0x00000086,0x00000087,
|
||||
0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,0x0000008a,0x00060050,0x0000000f,
|
||||
0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,0x00000075,0x00000091,0x0000008e,
|
||||
0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,0x00000091,0x0003003e,0x00000077,
|
||||
0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,0x00050041,0x00000009,0x00000096,
|
||||
0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,0x00050041,0x00000009,
|
||||
0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,0x00000099,0x00000098,0x0004007f,
|
||||
0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,0x0000009c,0x00000097,0x0000009a,
|
||||
0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,0x00000045,0x0000009c,0x00050091,
|
||||
0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,0x00000094,0x0000009e,0x0004003d,
|
||||
0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,0x000000a1,0x00000012,0x000500b8,
|
||||
0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,0x00000006,0x000000a3,0x000000a2,
|
||||
0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,0x00000020,0x00000078,0x00000046,
|
||||
0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,0x00000006,0x000000a6,0x000000a3,
|
||||
0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,0x00000054,0x00000054,0x00050091,
|
||||
0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,0x0000009f,0x000000a8,0x00050041,
|
||||
0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000aa,0x000000a9,
|
||||
0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,0x000300f7,0x000000ae,0x00000000,
|
||||
0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,0x000000ad,0x000200fe,0x000000b1,
|
||||
0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,0x00000020,0x0000007d,0x00000046,
|
||||
0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,0x00000006,0x000000b6,0x000000b5,
|
||||
0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,0x0004003d,0x00000006,0x000000b8,
|
||||
0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,0x000000b8,0x00050041,0x00000009,
|
||||
0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000bb,0x000000ba,0x00050088,
|
||||
0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,0x000000b3,0x000000bc,0x0004003d,
|
||||
0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,0x000000bf,0x00000094,0x0004003d,
|
||||
0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,0x000000c1,0x000000bf,0x000000c0,
|
||||
0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,0x0003003e,0x000000bd,0x000000c2,
|
||||
0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,0x00000046,0x0004003d,0x00000006,
|
||||
0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,0x0004003d,0x00000006,0x000000c7,
|
||||
0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,0x00000036,0x0000002b,0x0004003d,
|
||||
0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,0x000000ca,0x000000c7,0x000000c9,
|
||||
0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,0x000000cc,0x000000bd,0x00000024,
|
||||
0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,0x00000006,0x000000ce,0x000000c3,
|
||||
0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,0x00050041,0x00000009,0x000000d0,
|
||||
0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,0x000000d0,0x0004007f,0x00000006,
|
||||
0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,0x000000c6,0x00050088,0x00000006,
|
||||
0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,0x000000d5,0x000000cf,0x000000d4,
|
||||
0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,0x00050050,0x00000007,0x000000d7,
|
||||
0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,0x000000d6,0x000000d7,0x0003003e,
|
||||
0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,0x000000cb,0x000500b8,0x00000056,
|
||||
0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,0x000000db,0x000000da,0x000400a8,
|
||||
0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,0x00000000,0x000400fa,0x000000dc,
|
||||
0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,0x00000007,0x000000df,0x000000cb,
|
||||
0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,0x0004009a,0x0000003b,0x000000e1,
|
||||
0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,0x000700f5,0x0000003b,0x000000e2,
|
||||
0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,0x000000e4,0x00000000,0x000400fa,
|
||||
0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,0x000200fe,0x000000b1,0x000200f8,
|
||||
0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,0x0004003d,0x00000007,0x000000e7,
|
||||
0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,0x000000e7,0x0008004f,0x0000000f,
|
||||
0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,0x00000002,0x000200fe,0x000000e9,
|
||||
0x00010038
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <fcntl.h>
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
@@ -272,9 +271,7 @@ volatile int g_ubwc = 1;
|
||||
|
||||
volatile int g_no_render_node;
|
||||
|
||||
static atomic_int g_output_refresh_mhz = 60000;
|
||||
static atomic_int g_output_refresh_pending;
|
||||
static int g_output_announced_refresh_mhz = 60000;
|
||||
volatile int g_output_refresh_mhz;
|
||||
|
||||
volatile int g_output_w, g_output_h;
|
||||
struct pending_release {
|
||||
@@ -660,7 +657,6 @@ static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resou
|
||||
static struct surface *g_hud_surface;
|
||||
static int64_t g_hud_last_ns, g_hud_alive_ns;
|
||||
static int g_hud_fresh;
|
||||
static uint64_t g_game_frames;
|
||||
extern void droiddeck_on_game_surface(const char *window, const char *gpu);
|
||||
extern void droiddeck_on_game_frame(void);
|
||||
extern void droiddeck_on_game_program(int pid, const char *program);
|
||||
@@ -744,12 +740,10 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
}
|
||||
if (s == g_hud_surface) {
|
||||
g_hud_alive_ns = now_ns();
|
||||
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; g_game_frames++; droiddeck_on_game_frame(); }
|
||||
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; droiddeck_on_game_frame(); }
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t droiddeck_game_frames(void) { return g_game_frames; }
|
||||
|
||||
struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); }
|
||||
int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; }
|
||||
void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; }
|
||||
@@ -800,7 +794,7 @@ static void feedback_discard_all(struct wl_list *list) {
|
||||
}
|
||||
|
||||
static uint32_t nominal_refresh_ns(void) {
|
||||
int mhz = atomic_load(&g_output_refresh_mhz);
|
||||
int mhz = g_output_refresh_mhz;
|
||||
if (mhz <= 0) mhz = 60000;
|
||||
return (uint32_t)(1000000000000LL / (int64_t)mhz);
|
||||
}
|
||||
@@ -1513,10 +1507,10 @@ static const struct wl_output_interface output_impl = { .release = output_releas
|
||||
static struct wl_list g_outputs;
|
||||
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
|
||||
|
||||
static void output_send_mode(struct wl_resource *r, int mhz) {
|
||||
static void output_send_mode(struct wl_resource *r) {
|
||||
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
|
||||
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
|
||||
mhz);
|
||||
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
|
||||
}
|
||||
|
||||
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
@@ -1526,7 +1520,7 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
wl_list_insert(&g_outputs, wl_resource_get_link(r));
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
output_send_mode(r, g_output_announced_refresh_mhz);
|
||||
output_send_mode(r);
|
||||
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
|
||||
wl_output_send_name(r, "DroidDeck-1");
|
||||
wl_output_send_description(r, "DroidDeck display");
|
||||
@@ -1877,12 +1871,7 @@ static void render_scene(void) {
|
||||
else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0);
|
||||
else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver";
|
||||
}
|
||||
if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard &&
|
||||
!ahb_layer_only_candidate(w, h)) {
|
||||
/* The complete SDR scene stays on one display layer. Do not hide/re-show GAME
|
||||
* on each desktop frame just because it is not a single fullscreen window. */
|
||||
rendered = vkp_render(w, h, dl.d, dl.n) == 0;
|
||||
} else if (hdr_route == 0 || hdr_route == 3) {
|
||||
if (hdr_route == 0 || hdr_route == 3) {
|
||||
const int pass_on = fx_on || framegen;
|
||||
int li = (g_zero_copy && !framegen && !cardboard) ? layer_candidate(&dl, w, h) : -1;
|
||||
int over = li >= 0 ? dl.n - 1 - li : 0;
|
||||
@@ -2205,7 +2194,6 @@ struct constraint {
|
||||
int pending_region_set;
|
||||
int hint_set, pending_hint_set;
|
||||
double hint_x, hint_y, pending_hint_x, pending_hint_y;
|
||||
int gave_keys;
|
||||
};
|
||||
static struct wl_list g_constraints;
|
||||
static struct constraint *g_active_constraint;
|
||||
@@ -2284,14 +2272,6 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
|
||||
g_ptr_y = oy + k->hint_y;
|
||||
}
|
||||
g_raw_valid = 0;
|
||||
if (k->gave_keys) {
|
||||
k->gave_keys = 0;
|
||||
for (int i = 0; i < g_nkbs; i++) {
|
||||
if (!k->surface || g_kbs[i].focus != k->surface->resource) continue;
|
||||
wl_keyboard_send_leave(g_kbs[i].kb, wl_display_next_serial(g_display), g_kbs[i].focus);
|
||||
g_kbs[i].focus = NULL;
|
||||
}
|
||||
}
|
||||
if (tell_client) constraint_send_state(k, 0);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
|
||||
@@ -2310,12 +2290,6 @@ static void constraint_activate(struct constraint *k) {
|
||||
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
|
||||
constraint_origin(k, &ox, &oy);
|
||||
pointer_focus(k->surface->resource, wl_fixed_from_double(g_ptr_x - ox), wl_fixed_from_double(g_ptr_y - oy));
|
||||
int focused = 0;
|
||||
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus) focused = 1;
|
||||
if (k->is_lock && !focused && keyboard_for(wl_resource_get_client(k->surface->resource))) {
|
||||
keyboard_focus(k->surface->resource);
|
||||
k->gave_keys = 1;
|
||||
}
|
||||
constraint_send_state(k, 1);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
if (k->is_lock)
|
||||
@@ -2620,24 +2594,7 @@ static void pointer_delta(double dx, double dy) {
|
||||
}
|
||||
|
||||
static void pointer_button(uint32_t button, int pressed) {
|
||||
if (pressed) {
|
||||
pointer_input(0, 0, 1, button, pressed);
|
||||
return;
|
||||
}
|
||||
// Releases also clean up interrupted gestures. Deliver them to the existing
|
||||
// pointer focus without motion or hit-testing: native touch has independent
|
||||
// coordinates, so replaying g_ptr_x/y here can warp the game cursor.
|
||||
uint32_t t = now_ms();
|
||||
for (int i = 0; i < g_nptrs; i++) {
|
||||
struct seat_pointer *sp = &g_ptrs[i];
|
||||
if (!sp->focus) continue;
|
||||
wl_pointer_send_button(sp->ptr, wl_display_next_serial(g_display), t, button,
|
||||
WL_POINTER_BUTTON_STATE_RELEASED);
|
||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
|
||||
wl_pointer_send_frame(sp->ptr);
|
||||
}
|
||||
g_grab = NULL;
|
||||
wl_display_flush_clients(g_display);
|
||||
pointer_input(0, 0, 1, button, pressed);
|
||||
}
|
||||
|
||||
static void deliver_pointer(const struct input_msg *m) {
|
||||
@@ -2841,19 +2798,10 @@ static void deliver_key(const struct input_msg *m) {
|
||||
key_event((uint32_t)m->p1, m->p2);
|
||||
}
|
||||
|
||||
static void send_axis(struct seat_pointer *sp, uint32_t t, uint32_t axis, int value120) {
|
||||
if (!value120) return;
|
||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION)
|
||||
wl_pointer_send_axis_value120(sp->ptr, axis, value120);
|
||||
else if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_DISCRETE_SINCE_VERSION && value120 % 120 == 0)
|
||||
wl_pointer_send_axis_discrete(sp->ptr, axis, value120 / 120);
|
||||
wl_pointer_send_axis(sp->ptr, t, axis, wl_fixed_from_double(value120 / 12.0));
|
||||
}
|
||||
|
||||
static void axis_event(int vertical120, int horizontal120) {
|
||||
static void scroll_event(int steps) {
|
||||
struct surface *target = g_active_constraint ? g_active_constraint->surface
|
||||
: g_desktop ? g_desktop : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y));
|
||||
if (!target || (!vertical120 && !horizontal120)) return;
|
||||
if (!target || !steps) return;
|
||||
struct wl_client *client = wl_resource_get_client(target->resource);
|
||||
struct seat_pointer *sp;
|
||||
if (!pointer_for(client)) return;
|
||||
@@ -2862,18 +2810,17 @@ static void axis_event(int vertical120, int horizontal120) {
|
||||
uint32_t t = now_ms();
|
||||
pointer_focus(target->resource, wl_fixed_from_double(g_ptr_x - tx), wl_fixed_from_double(g_ptr_y - ty));
|
||||
for_each_pointer_of(client, sp) {
|
||||
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
|
||||
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
|
||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION)
|
||||
wl_pointer_send_axis_value120(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps * 120);
|
||||
else if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_DISCRETE_SINCE_VERSION)
|
||||
wl_pointer_send_axis_discrete(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps);
|
||||
wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL, wl_fixed_from_int(steps * 10));
|
||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
|
||||
wl_pointer_send_frame(sp->ptr);
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
static void scroll_event(int steps) {
|
||||
axis_event(steps * 120, 0);
|
||||
}
|
||||
|
||||
static int update_modifiers(uint32_t evdev, int pressed) {
|
||||
static const struct { uint32_t evdev, mask; } mod_keys[] = {
|
||||
{ 42, 0x1 }, { 54, 0x1 },
|
||||
@@ -2929,19 +2876,13 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
static void output_notify_mode(void) {
|
||||
int mhz = atomic_load(&g_output_refresh_mhz);
|
||||
struct wl_resource *r;
|
||||
wl_resource_for_each(r, &g_outputs) {
|
||||
output_send_mode(r, mhz);
|
||||
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
|
||||
}
|
||||
g_output_announced_refresh_mhz = mhz;
|
||||
}
|
||||
|
||||
static void output_resized(void) {
|
||||
struct surface *s;
|
||||
output_notify_mode();
|
||||
struct wl_resource *r;
|
||||
wl_resource_for_each(r, &g_outputs) {
|
||||
output_send_mode(r);
|
||||
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
|
||||
}
|
||||
wl_list_for_each(s, &g_surfaces, link)
|
||||
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
|
||||
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
|
||||
@@ -2950,46 +2891,22 @@ static void output_resized(void) {
|
||||
|
||||
static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
struct input_msg m;
|
||||
int vsync = 0;
|
||||
int64_t vsync_ns = 0;
|
||||
while (read(fd, &m, sizeof(m)) == (ssize_t)sizeof(m)) {
|
||||
switch (m.type) {
|
||||
case 1: deliver_key(&m); break;
|
||||
case 2: pointer_event(m.p1, m.p2, 0, 0); break;
|
||||
case 3: pointer_button(m.p1, m.p2); break;
|
||||
case 4: scroll_event(m.p1); break;
|
||||
case 5: vsync = 1; vsync_ns = ((int64_t)m.p1 << 32) | (uint32_t)m.p2; break;
|
||||
case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
|
||||
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break;
|
||||
case 7: deliver_touch(&m); break;
|
||||
case 8: output_resized(); break;
|
||||
case 9:
|
||||
vk_present_set_scale_mode(m.p1, m.p2);
|
||||
schedule_render();
|
||||
break;
|
||||
case 10: break;
|
||||
case 11: axis_event(m.p1, m.p2); break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
if (atomic_exchange(&g_output_refresh_pending, 0) &&
|
||||
atomic_load(&g_output_refresh_mhz) != g_output_announced_refresh_mhz) {
|
||||
output_notify_mode();
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
if (vsync) on_vsync(vsync_ns);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_set_scale_mode(int mode, int alignment) {
|
||||
if (g_input_pipe[1] < 0) {
|
||||
vk_present_set_scale_mode(mode, alignment);
|
||||
return;
|
||||
}
|
||||
struct input_msg m = { 9, mode, alignment, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_send_pointer(int action, int x, int y) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 0, action, x, y, 0 };
|
||||
@@ -3018,16 +2935,6 @@ void droiddeck_wayland_output_resized(void) {
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_set_output_refresh_rate(int mhz) {
|
||||
if (mhz <= 0) mhz = 60000;
|
||||
if (atomic_exchange(&g_output_refresh_mhz, mhz) == mhz || g_input_pipe[1] < 0) return;
|
||||
atomic_store(&g_output_refresh_pending, 1);
|
||||
struct input_msg m = { 10, 0, 0, 0, 0 };
|
||||
ssize_t n;
|
||||
do { n = write(g_input_pipe[1], &m, sizeof(m)); } while (n < 0 && errno == EINTR);
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 };
|
||||
@@ -3203,9 +3110,6 @@ int droiddeck_wayland_run(void) {
|
||||
open_session_log();
|
||||
|
||||
if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn");
|
||||
/* Presentation compatibility is not HDR capability. Set the initial layer mode before
|
||||
* clients bind, including when Desktop is the first session in this app process. */
|
||||
if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1;
|
||||
|
||||
struct wl_display *display = wl_display_create();
|
||||
if (!display) {
|
||||
@@ -3265,7 +3169,6 @@ int droiddeck_wayland_run(void) {
|
||||
if ((g_stats_timer = wl_event_loop_add_timer(wl_display_get_event_loop(display), on_stats_timer, NULL)))
|
||||
wl_event_source_timer_update(g_stats_timer, 10000);
|
||||
|
||||
g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz);
|
||||
wl_display_run(display);
|
||||
|
||||
wl_display_destroy(display);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <android/log.h>
|
||||
|
||||
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
uint64_t droiddeck_game_frames(void);
|
||||
#define FGLOG(...) droiddeck_log("framegen", __VA_ARGS__)
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "DroidDeckWayland", __VA_ARGS__)
|
||||
|
||||
@@ -373,11 +372,7 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
|
||||
* generations would otherwise read as a slower game and justify even more generations. */
|
||||
++g_source_frames;
|
||||
const uint64_t game_frames = droiddeck_game_frames();
|
||||
uint32_t n_gen = fge_plan(g_ring_n, game_frames ? game_frames : g_source_frames, g_presented_rate);
|
||||
uint32_t n_gen = fge_plan(g_ring_n, ++g_source_frames, g_presented_rate);
|
||||
if (n_gen > g_ring_n) n_gen = g_ring_n;
|
||||
g_planned = n_gen;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
enum {
|
||||
DROIDDECK_HEADPOSE_LIVE = 1u << 0,
|
||||
DROIDDECK_HEADPOSE_ENABLED = 1u << 1,
|
||||
DROIDDECK_HEADPOSE_MONO = 1u << 2,
|
||||
};
|
||||
|
||||
struct droiddeck_headpose {
|
||||
@@ -23,10 +22,7 @@ struct droiddeck_headpose {
|
||||
float tan_half_fov;
|
||||
float eye_aspect;
|
||||
uint32_t eye_w, eye_h;
|
||||
float display_hz;
|
||||
// Uses a formerly reserved word; older publishers leave this zero.
|
||||
uint32_t render_height;
|
||||
uint32_t reserved[10];
|
||||
uint32_t reserved[12];
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#ifndef DROIDDECK_QUEST_DEVICE_H
|
||||
#define DROIDDECK_QUEST_DEVICE_H
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
/* Presentation only; this does not select a GPU driver or expand driver support. */
|
||||
static inline int quest_device_matches(const char *model, const char *device, const char *maker) {
|
||||
return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") ||
|
||||
!strcasecmp(model, "Quest 3S") ||
|
||||
((!strcasecmp(maker, "Oculus") || !strcasecmp(maker, "Meta")) &&
|
||||
(!strcmp(device, "hollywood") || !strcmp(device, "eureka") || !strcmp(device, "panther")));
|
||||
}
|
||||
#endif
|
||||
@@ -185,8 +185,6 @@ static int g_pending_cb;
|
||||
static unsigned g_stat_layer_frames;
|
||||
static int g_two_logged;
|
||||
static AHardwareBuffer *g_blank;
|
||||
static ASurfaceControl *g_base_sc;
|
||||
static int g_base_w, g_base_h, g_base_shown;
|
||||
|
||||
static void layers_init(void) {
|
||||
if (g_layers_ready) return;
|
||||
@@ -264,58 +262,14 @@ static AHardwareBuffer *blank_buffer(void) {
|
||||
AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY};
|
||||
if (AHardwareBuffer_allocate(&d, &g_blank) != 0 || !g_blank) { g_blank = NULL; return NULL; }
|
||||
void *p = NULL;
|
||||
int locked = AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p);
|
||||
if (locked != 0 || !p) {
|
||||
if (locked == 0) AHardwareBuffer_unlock(g_blank, NULL);
|
||||
AHardwareBuffer_release(g_blank); g_blank = NULL;
|
||||
return NULL;
|
||||
}
|
||||
AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got);
|
||||
memset(p, 0, (size_t)got.stride * 4 * got.height);
|
||||
if (AHardwareBuffer_unlock(g_blank, NULL) != 0) {
|
||||
AHardwareBuffer_release(g_blank); g_blank = NULL;
|
||||
return NULL;
|
||||
if (AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p) == 0 && p) {
|
||||
AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got);
|
||||
memset(p, 0, (size_t)got.stride * 4 * 16);
|
||||
AHardwareBuffer_unlock(g_blank, NULL);
|
||||
}
|
||||
return g_blank;
|
||||
}
|
||||
|
||||
int sc_layer_base_black(int width, int height) {
|
||||
if (width <= 0 || height <= 0 || load_api() != 0 || !vkp_window()) return -1;
|
||||
if (g_base_sc && g_base_shown && width == g_base_w && height == g_base_h) return 0;
|
||||
AHardwareBuffer *black = blank_buffer();
|
||||
if (!black) return -1;
|
||||
if (!g_base_sc) g_base_sc = api.createFromWindow(vkp_window(), "droiddeck_wayland_black");
|
||||
if (!g_base_sc) return -1;
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
if (!tx) return -1;
|
||||
ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height};
|
||||
api.setBuffer(tx, g_base_sc, black, -1);
|
||||
api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE);
|
||||
api.setZOrder(tx, g_base_sc, 0); /* GAME = 1, OVERLAY = 2 */
|
||||
api.setGeometry(tx, g_base_sc, &src, &dst, 0);
|
||||
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
g_base_w = width; g_base_h = height; g_base_shown = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void base_hide(int retire) {
|
||||
if (!g_base_sc) return;
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
if (tx) {
|
||||
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_HIDE);
|
||||
if (retire) api.reparent(tx, g_base_sc, NULL);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
g_base_shown = 0;
|
||||
}
|
||||
if (retire) {
|
||||
api.release(g_base_sc); g_base_sc = NULL;
|
||||
g_base_w = g_base_h = g_base_shown = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void sc_layer_hide_base(void) { base_hide(0); }
|
||||
|
||||
static void replace_with_blank(ASurfaceTransaction *tx, struct layer *l) {
|
||||
AHardwareBuffer *b = blank_buffer();
|
||||
if (b) api.setBuffer(tx, l->sc, b, -1);
|
||||
@@ -973,7 +927,6 @@ void sc_layer_hide_overlay(void) {
|
||||
}
|
||||
|
||||
void sc_layer_window_gone(void) {
|
||||
base_hide(1); /* the backdrop belongs to the old window, just like GAME and OVERLAY */
|
||||
if (api.state != 1) return;
|
||||
layers_init();
|
||||
for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]);
|
||||
|
||||
@@ -36,8 +36,6 @@ int sc_layer_overlay_affordable(void);
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
|
||||
|
||||
void sc_layer_hide(void);
|
||||
int sc_layer_base_black(int width, int height);
|
||||
void sc_layer_hide_base(void);
|
||||
void sc_layer_hide_overlay(void);
|
||||
|
||||
void sc_layer_window_gone(void);
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "quest_device.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/system_properties.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
@@ -66,21 +64,6 @@ static VkExtent2D g_extent;
|
||||
static int g_first_frame_done;
|
||||
|
||||
static int g_base_black;
|
||||
|
||||
int vkp_quest_layer_presentation(void) {
|
||||
/* Compositor thread only; this is a process-wide device property, not a session setting.
|
||||
* Quest 2 still needs its separate A6xx driver: this gate only chooses presentation. */
|
||||
static int enabled = -1;
|
||||
if (enabled < 0) {
|
||||
char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0};
|
||||
__system_property_get("ro.product.model", model);
|
||||
__system_property_get("ro.product.device", device);
|
||||
__system_property_get("ro.product.manufacturer", maker);
|
||||
enabled = quest_device_matches(model, device, maker);
|
||||
if (enabled) droiddeck_log("gpu", "Quest presentation: display layers for Steam and desktop, CPU-initialized black backdrop; independent of HDR");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
static VkExtent2D g_caps_extent;
|
||||
static int64_t g_surface_checked_ns;
|
||||
|
||||
@@ -138,9 +121,6 @@ static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static ANativeWindow *g_req_window;
|
||||
static int g_req_pending;
|
||||
static int g_req_detached;
|
||||
static uint64_t g_req_generation, g_req_applied;
|
||||
static pthread_cond_t g_req_condition = PTHREAD_COND_INITIALIZER;
|
||||
|
||||
|
||||
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
|
||||
|
||||
@@ -150,9 +130,6 @@ static struct {
|
||||
int rx, ry, rw, rh;
|
||||
int valid;
|
||||
} g_map;
|
||||
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static float g_game_picture_rect[4];
|
||||
static int g_game_picture_top;
|
||||
|
||||
static int64_t g_swap_retry_at_ns;
|
||||
static int g_swap_fail_logged;
|
||||
@@ -221,53 +198,26 @@ void vk_present_set_window(ANativeWindow *window) {
|
||||
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
|
||||
g_req_window = window;
|
||||
g_req_pending = 1;
|
||||
g_req_generation++;
|
||||
if (!window) g_req_detached = 1;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (superseded) ANativeWindow_release(superseded);
|
||||
}
|
||||
|
||||
static void window_request_applied(uint64_t generation) {
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
g_req_applied = generation;
|
||||
pthread_cond_broadcast(&g_req_condition);
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
}
|
||||
|
||||
int vkp_wait_window_request(int timeout_ms) {
|
||||
if (timeout_ms <= 0 || timeout_ms > 10000) return 0;
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += timeout_ms / 1000;
|
||||
deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
|
||||
if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
const uint64_t generation = g_req_generation;
|
||||
int error = 0;
|
||||
while (g_req_applied < generation && error == 0)
|
||||
error = pthread_cond_timedwait(&g_req_condition, &g_req_lock, &deadline);
|
||||
int applied = g_req_applied >= generation;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
return applied;
|
||||
}
|
||||
|
||||
int vkp_apply_window_request(void) {
|
||||
ANativeWindow *w;
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
|
||||
w = g_req_window;
|
||||
const int detached = g_req_detached;
|
||||
const uint64_t generation = g_req_generation;
|
||||
g_req_window = NULL;
|
||||
g_req_pending = 0;
|
||||
g_req_detached = 0;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (w == g_window) {
|
||||
if (w) ANativeWindow_release(w);
|
||||
if (!w || !detached) { window_request_applied(generation); return 0; }
|
||||
if (!w || !detached) return 0;
|
||||
sc_layer_window_gone();
|
||||
destroy_swapchain();
|
||||
window_request_applied(generation);
|
||||
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
|
||||
return 1;
|
||||
}
|
||||
@@ -278,7 +228,6 @@ int vkp_apply_window_request(void) {
|
||||
g_window = w;
|
||||
g_swap_retry_at_ns = 0;
|
||||
g_swap_fail_logged = 0;
|
||||
window_request_applied(generation);
|
||||
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
|
||||
return 1;
|
||||
}
|
||||
@@ -295,7 +244,7 @@ int vkp_device_lost(void) { return g_dev_state == -2; }
|
||||
|
||||
void vk_present_set_scale_mode(int mode, int alignment) {
|
||||
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
g_mode = mode;
|
||||
g_align = alignment;
|
||||
}
|
||||
@@ -314,8 +263,6 @@ static const char *align_name(int a) {
|
||||
switch (a) {
|
||||
case VKP_ALIGN_TOP: return "top";
|
||||
case VKP_ALIGN_BOTTOM: return "bottom";
|
||||
case VKP_ALIGN_PICTURE_TOP: return "picture top";
|
||||
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
|
||||
default: return "center";
|
||||
}
|
||||
}
|
||||
@@ -351,30 +298,13 @@ static void update_map(int scene_w, int scene_h) {
|
||||
g_map.off_x = (float)(g_map.rx + (int)((g_map.rw - scene_w * aspect) * 0.5f));
|
||||
g_map.off_y = (float)(g_map.ry + (int)((g_map.rh - scene_h * aspect) * 0.5f));
|
||||
}
|
||||
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
|
||||
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
|
||||
g_map.valid = 1;
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
|
||||
g_game_picture_rect[0] = g_map.off_x;
|
||||
g_game_picture_rect[1] = g_map.off_y;
|
||||
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
|
||||
g_game_picture_rect[3] = g_map.off_y + scene_h * g_map.ky;
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
|
||||
mode_name(mode), align_name(align), scene_w, scene_h,
|
||||
(int)(scene_w * g_map.kx + 0.5f), (int)(scene_h * g_map.ky + 0.5f),
|
||||
(int)g_map.off_x, (int)g_map.off_y, W, H);
|
||||
}
|
||||
|
||||
int vkp_game_picture_rect(float rect[4]) {
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
int active = g_game_picture_top;
|
||||
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
return active;
|
||||
}
|
||||
|
||||
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy) {
|
||||
if (!g_map.valid || g_map.kx <= 0 || g_map.ky <= 0) return 0;
|
||||
*sx = (ox - g_map.off_x) / g_map.kx;
|
||||
@@ -1383,10 +1313,6 @@ static void check_surface_changed(void) {
|
||||
|
||||
int vkp_base_black(int scene_w, int scene_h) {
|
||||
vkp_apply_window_request();
|
||||
if (vkp_quest_layer_presentation()) {
|
||||
if (vkp_update_map(scene_w, scene_h) != 0) return -1;
|
||||
return sc_layer_base_black((int)g_extent.width, (int)g_extent.height);
|
||||
}
|
||||
check_surface_changed();
|
||||
if (g_base_black && g_swapchain && g_window && g_dev_state == 1) {
|
||||
g_perf.base_kept++;
|
||||
@@ -1465,7 +1391,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
const struct vkp_hdr_frame *hf, int *how) {
|
||||
if (how) *how = 0;
|
||||
vkp_apply_window_request();
|
||||
sc_layer_hide_base();
|
||||
check_surface_changed();
|
||||
if (g_dev_state == -2) return -1;
|
||||
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
@@ -1723,26 +1648,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
}
|
||||
|
||||
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
|
||||
if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) {
|
||||
/* The desktop (multiple windows), overlays and an empty scene must also avoid WSI.
|
||||
* Merely enabling zero-copy handles only a single fullscreen window and leaves both
|
||||
* the letterbox and every fallback frame going through the corrupted swapchain. */
|
||||
vkp_apply_window_request();
|
||||
sc_layer_hide_overlay();
|
||||
if (n > 0) {
|
||||
if (sc_layer_present_pass(draws, n, scene_w, scene_h) != 0) {
|
||||
static int said;
|
||||
if (!said) { said = 1; droiddeck_log("error", "Quest scene layer unavailable; retaining the previous frame rather than using the corrupt Android swapchain"); }
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
sc_layer_hide();
|
||||
}
|
||||
/* sc_layer_present_pass signals only after submitting client content.
|
||||
* A backdrop alone, or a successful no-op while a layer slot is unavailable,
|
||||
* must not dismiss the app's loading UI or mark Steam's window as shown. */
|
||||
return vkp_base_black(scene_w, scene_h);
|
||||
}
|
||||
return render_impl(scene_w, scene_h, draws, n, NULL, NULL);
|
||||
}
|
||||
|
||||
@@ -1776,15 +1681,6 @@ void vkp_signal_first_frame(void) {
|
||||
|
||||
int vkp_update_map(int scene_w, int scene_h) {
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
if (vkp_quest_layer_presentation()) {
|
||||
/* Layer geometry needs the window size, not a Vulkan swapchain. In particular, do
|
||||
* not create/present a swapchain just to paint black under an otherwise clean layer. */
|
||||
int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window);
|
||||
if (w <= 0 || h <= 0) return -1;
|
||||
g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h};
|
||||
update_map(scene_w, scene_h);
|
||||
return g_map.valid ? 0 : -1;
|
||||
}
|
||||
check_surface_changed();
|
||||
if (!g_swapchain && swap_init() != 0) return -1;
|
||||
update_map(scene_w, scene_h);
|
||||
@@ -1993,7 +1889,11 @@ int vkp_pass_begin_hdr(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draws, int n,
|
||||
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
|
||||
if (g_pass.active) vkp_pass_abort();
|
||||
if (vkp_update_map(scene_w, scene_h) != 0) return -1;
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
check_surface_changed();
|
||||
if (!g_swapchain && swap_init() != 0) return -1;
|
||||
update_map(scene_w, scene_h);
|
||||
if (!g_map.valid) return -1;
|
||||
if (hf) {
|
||||
if (ensure_img(&g_hdrscene, scene_w, scene_h, HDR_FMT,
|
||||
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT |
|
||||
|
||||
@@ -8,13 +8,10 @@ void vk_present_set_driver(const char *driver_path, const char *library_name,
|
||||
void vk_present_set_window(ANativeWindow *window);
|
||||
int vkp_apply_window_request(void);
|
||||
|
||||
int vkp_quest_layer_presentation(void);
|
||||
|
||||
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
|
||||
VKP_MODE_INTEGER = 4 };
|
||||
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
|
||||
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2 };
|
||||
void vk_present_set_scale_mode(int mode, int alignment);
|
||||
int vkp_game_picture_rect(float rect[4]);
|
||||
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
|
||||
void vkp_output_size(int *w, int *h);
|
||||
|
||||
@@ -51,7 +48,6 @@ const char *vkp_gpu_name(void);
|
||||
|
||||
int vkp_ready(void);
|
||||
int vkp_has_window(void);
|
||||
int vkp_wait_window_request(int timeout_ms);
|
||||
int vkp_device_lost(void);
|
||||
|
||||
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n);
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
#include "droiddeck_color.h"
|
||||
|
||||
extern int droiddeck_wayland_run(void);
|
||||
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
|
||||
extern void droiddeck_wayland_send_pointer(int action, int x, int y);
|
||||
extern void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y);
|
||||
extern int droiddeck_cursor_snapshot(int *out, int cap);
|
||||
@@ -27,13 +26,13 @@ extern void droiddeck_wayland_send_key(int evdev, int state);
|
||||
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
|
||||
extern void droiddeck_wayland_output_resized(void);
|
||||
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
|
||||
extern volatile int g_fps_limit;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
extern volatile unsigned g_zero_copy_last;
|
||||
extern volatile int g_ubwc;
|
||||
extern volatile int g_no_render_node;
|
||||
extern volatile int g_output_refresh_mhz;
|
||||
extern volatile int g_output_w, g_output_h;
|
||||
|
||||
#define TAG "DroidDeckWayland"
|
||||
@@ -328,7 +327,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLayerFrameRate(JN
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) {
|
||||
droiddeck_wayland_set_output_refresh_rate(hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0);
|
||||
g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0;
|
||||
}
|
||||
|
||||
extern void vkp_reset_first_frame(void);
|
||||
@@ -342,7 +341,6 @@ JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv *env, jclass clazz, jint w, jint h) {
|
||||
g_output_w = w > 0 ? w : 0;
|
||||
g_output_h = h > 0 ? h : 0;
|
||||
vkp_cardboard_set_render_height(h);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
@@ -351,26 +349,16 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv
|
||||
if (w <= 0 || h <= 0) return;
|
||||
g_output_w = w;
|
||||
g_output_h = h;
|
||||
vkp_cardboard_set_render_height(h);
|
||||
droiddeck_wayland_output_resized();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScaleMode(JNIEnv *env, jclass clazz, jint mode, jint alignment) {
|
||||
droiddeck_wayland_set_scale_mode(mode, alignment);
|
||||
vk_present_set_scale_mode(mode, alignment);
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "scale mode %d alignment %d", mode, alignment);
|
||||
}
|
||||
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGamePictureRect(JNIEnv *env, jclass clazz, jfloatArray out) {
|
||||
if (!out || (*env)->GetArrayLength(env, out) < 4) return JNI_FALSE;
|
||||
float rect[4];
|
||||
if (!vkp_game_picture_rect(rect)) return JNI_FALSE;
|
||||
(*env)->SetFloatArrayRegion(env, out, 0, 4, rect);
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUpscaler(JNIEnv *env, jclass clazz, jint mode) {
|
||||
vkp_effects_set_scaling(mode);
|
||||
@@ -404,11 +392,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboard(JNIEnv *env, jclass clazz, jboolean enabled,
|
||||
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma,
|
||||
jboolean mono, jfloat hz) {
|
||||
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma) {
|
||||
struct cardboard_params p = {.enabled = enabled ? 1 : 0, .source = source, .ipd_m = ipd, .screen_dist_m = dist,
|
||||
.screen_width_m = width, .k1 = k1, .k2 = k2, .fov_deg = fov, .lens_inset = inset,
|
||||
.chroma = chroma, .mono = mono ? 1 : 0, .display_hz = hz};
|
||||
.chroma = chroma};
|
||||
vkp_cardboard_set(&p);
|
||||
}
|
||||
|
||||
@@ -595,12 +582,6 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeWaitSurface(
|
||||
JNIEnv *env, jclass clazz, jint timeout_ms) {
|
||||
return vkp_wait_window_request(timeout_ms) ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
|
||||
int len;
|
||||
|
||||
@@ -16,17 +16,11 @@ class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// The restart screen must not initialize USB, guest processes, or another graphics context.
|
||||
val process = (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
|
||||
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
|
||||
if (process == "$packageName:vr_recovery") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
com.droiddeck.launcher.core.DownloadSource.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
com.droiddeck.launcher.glasses.Glasses.init(this)
|
||||
BwrapSpawner.start(this)
|
||||
com.droiddeck.launcher.frontend.GameFileSync.start(this)
|
||||
com.droiddeck.launcher.stores.StoresState.init(this)
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import com.droiddeck.launcher.gpu.AutoDriverTrial
|
||||
import com.droiddeck.launcher.gpu.DriverBundle
|
||||
import com.droiddeck.launcher.gpu.DriverPairs
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
@@ -21,13 +20,7 @@ import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.QuestExperimental
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
import com.droiddeck.launcher.ui.DriverSlot
|
||||
import com.droiddeck.launcher.ui.GPU_MODE_AUTO
|
||||
import com.droiddeck.launcher.ui.GPU_MODE_MIX
|
||||
import com.droiddeck.launcher.ui.GPU_MODE_PAIR
|
||||
import com.droiddeck.launcher.ui.GpuDriversState
|
||||
import com.droiddeck.launcher.ui.PairRow
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
@@ -48,18 +41,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
var pairPercent by mutableIntStateOf(-1)
|
||||
var autoStatus by mutableStateOf("")
|
||||
var activeBundle by mutableStateOf<String?>(null)
|
||||
private var lastAutoCheck = 0L
|
||||
private var pairModeOpen by mutableStateOf(false)
|
||||
var restartPending by mutableStateOf(false)
|
||||
private set
|
||||
private var runtimePending by mutableStateOf(false)
|
||||
private var sessionRunning by mutableStateOf(false)
|
||||
|
||||
val uiMode: String get() = when (mode) {
|
||||
SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO
|
||||
SessionPrefs.GPU_DRIVERS_MIX -> GPU_MODE_MIX
|
||||
else -> if (pairModeOpen || pairRows.any { it.active }) GPU_MODE_PAIR else GPU_MODE_MIX
|
||||
}
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
|
||||
@@ -69,56 +51,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
|
||||
canRestart = !sessionRunning,
|
||||
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
|
||||
)
|
||||
|
||||
private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when {
|
||||
uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto
|
||||
uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair
|
||||
activeBundle != null -> R.string.gpu_src_bundle
|
||||
chosenDefault -> defaultLabel
|
||||
else -> R.string.gpu_src_chosen
|
||||
})
|
||||
|
||||
private fun displaySlot(): DriverSlot {
|
||||
val row = androidRows.firstOrNull { it.id == androidSelected }
|
||||
val live = androidRows.firstOrNull { it.id == CompositorHost.displayChoice }?.name
|
||||
return DriverSlot(
|
||||
name = row?.name ?: activity.getString(R.string.gpu_auto_by_gpu),
|
||||
source = slotSource(androidSelected == TurnipDriver.AUTO, R.string.gpu_src_by_gpu),
|
||||
pending = restartPending,
|
||||
status = when {
|
||||
restartPending -> activity.getString(R.string.gpu_display_pending, live ?: activity.getString(R.string.gpu_previous_driver))
|
||||
CompositorHost.isStarted -> activity.getString(R.string.gpu_display_live)
|
||||
else -> activity.getString(R.string.gpu_display_next)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun runtimeSlot(): DriverSlot {
|
||||
val row = linuxRows.firstOrNull { it.id == linuxSelected }
|
||||
val live = SessionState.runtimeDriver?.let { id -> linuxRows.firstOrNull { it.id == id }?.name }
|
||||
return DriverSlot(
|
||||
name = row?.name ?: activity.getString(R.string.gpu_runtime_default),
|
||||
source = slotSource(linuxSelected.isEmpty(), R.string.gpu_src_default),
|
||||
pending = runtimePending,
|
||||
status = when {
|
||||
runtimePending -> activity.getString(R.string.gpu_runtime_pending, live ?: activity.getString(R.string.gpu_previous_driver))
|
||||
sessionRunning -> activity.getString(R.string.gpu_runtime_live)
|
||||
else -> activity.getString(R.string.gpu_runtime_hint)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun restartApp() {
|
||||
if (SessionState.running) return
|
||||
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
|
||||
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
@@ -156,30 +89,21 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
activeBundle = DriverBundle.active(activity)?.label
|
||||
restartPending = CompositorHost.isStarted && CompositorHost.displayChoice.let { it != null && it != androidSelected }
|
||||
sessionRunning = SessionState.running
|
||||
runtimePending = sessionRunning && SessionState.runtimeDriver.let { it != null && it != linuxSelected }
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
}
|
||||
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
val display = displaySlot().name
|
||||
val base = when {
|
||||
uiMode == GPU_MODE_AUTO -> activity.getString(R.string.drivers_summary, activity.getString(R.string.common_auto), active ?: display)
|
||||
uiMode == GPU_MODE_PAIR && active != null -> activity.getString(R.string.drivers_summary, activity.getString(R.string.gpu_mode_pair_short), active)
|
||||
else -> activity.getString(R.string.drivers_summary, activity.getString(R.string.gpu_mode_mix_short),
|
||||
runtimeSlot().name.let { if (it == display) it else activity.getString(R.string.gpu_summary_mix, display, it) })
|
||||
}
|
||||
return if (restartPending) activity.getString(R.string.gpu_summary_restart, base) else base
|
||||
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
|
||||
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
|
||||
}
|
||||
|
||||
fun refreshPairs() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs)
|
||||
val recommended = DriverPairs.recommendedKey(gpu, pairs)
|
||||
pairRows = pairs.map { p ->
|
||||
val bundle = p.bundle?.let { installedBundle(it) }
|
||||
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
|
||||
@@ -196,56 +120,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
}.sortedByDescending { it.recommended }
|
||||
}
|
||||
|
||||
fun chooseMode(next: String) {
|
||||
pairModeOpen = next == GPU_MODE_PAIR
|
||||
setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX)
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean, mix: Boolean = false) {
|
||||
mode = when {
|
||||
auto -> SessionPrefs.GPU_DRIVERS_AUTO
|
||||
mix -> SessionPrefs.GPU_DRIVERS_MIX
|
||||
else -> SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
}
|
||||
fun setMode(auto: Boolean) {
|
||||
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
SessionPrefs.setGpuDriverMode(activity, mode)
|
||||
autoStatus = ""
|
||||
if (auto) ensureAuto(force = false)
|
||||
}
|
||||
|
||||
fun checkOnOpen() {
|
||||
if (SessionPrefs.gpuDriverMode(activity) == SessionPrefs.GPU_DRIVERS_AUTO) ensureAuto(force = false)
|
||||
else if (!releaseChecking && releasesStale() && com.droiddeck.launcher.frontend.AutoComponents.isOnline(activity)) checkLatestTurnip()
|
||||
}
|
||||
|
||||
private fun releasesStale(): Boolean {
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
return cached == null || System.currentTimeMillis() - cached.checkedAt > RELEASE_CHECK_INTERVAL_MS ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun ensureAuto(force: Boolean) {
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
|
||||
if (SessionState.running) {
|
||||
if (force) autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
|
||||
return
|
||||
}
|
||||
AutoDriverTrial.rollbackDue(activity)?.let { trial ->
|
||||
AutoDriverTrial.rollBack(activity, trial)
|
||||
autoStatus = activity.getString(R.string.drivers_rolled_back, trial.name)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (!force && System.currentTimeMillis() - lastAutoCheck < RELEASE_CHECK_INTERVAL_MS) return
|
||||
lastAutoCheck = System.currentTimeMillis()
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != QuestExperimental.defaultAndroidDriver()
|
||||
if (QuestExperimental.applyAutoDrivers(activity)) {
|
||||
autoStatus = activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_quest_restart else R.string.drivers_using_quest,
|
||||
)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
|
||||
return
|
||||
@@ -253,8 +139,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
releaseChecking = true
|
||||
autoStatus = activity.getString(R.string.drivers_checking_latest)
|
||||
Thread({
|
||||
val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity)
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
@@ -268,10 +156,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
?: activity.getString(R.string.upd_current_status)
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
|
||||
AutoDriverTrial.release(pair) in AutoDriverTrial.bad(activity) ->
|
||||
autoStatus = activity.getString(R.string.drivers_skipping_bad, pair.name, pair.version)
|
||||
SessionState.running -> autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
|
||||
holdBack && row?.installed != true -> autoStatus = activity.getString(R.string.drivers_auto_waits_for_network, pair.name, pair.version)
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
@@ -281,8 +165,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun selectPair(key: String) {
|
||||
if (pairBusy != null) return
|
||||
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
|
||||
pairModeOpen = true
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_MANUAL) setMode(auto = false)
|
||||
installPair(pair, auto = false)
|
||||
}
|
||||
|
||||
@@ -292,6 +174,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
var downloaded = false
|
||||
val result = runCatching {
|
||||
val zips = pair.assets
|
||||
@@ -305,27 +189,22 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
pairBusy = null
|
||||
pairPercent = -1
|
||||
result.onSuccess { (displayId, linuxId) ->
|
||||
if (auto && SessionState.running) {
|
||||
lastAutoCheck = 0L
|
||||
autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
|
||||
return@onSuccess
|
||||
}
|
||||
val previousDisplay = SessionPrefs.androidDriver(activity)
|
||||
val previousLinux = SessionPrefs.linuxDriver(activity)
|
||||
val displayChanged = previousDisplay != displayId
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = displayChanged && CompositorHost.isStarted
|
||||
if (auto) {
|
||||
val trial = AutoDriverTrial.current(activity)
|
||||
val lastGood = trial?.let { setOf(it.previousDisplay, it.previousLinux) } ?: setOf(previousDisplay, previousLinux)
|
||||
val changed = previousDisplay != displayId || previousLinux != linuxId
|
||||
SessionPrefs.setGpuAutoInstalled(activity, SessionPrefs.gpuAutoInstalled(activity) + setOf(displayId, linuxId))
|
||||
if (changed) AutoDriverTrial.begin(
|
||||
activity, AutoDriverTrial.release(pair), "${pair.name} ${pair.version}", displayId, linuxId,
|
||||
trial?.previousDisplay ?: previousDisplay, trial?.previousLinux ?: previousLinux,
|
||||
)
|
||||
if (!SessionState.running) AutoDriverTrial.prune(activity, lastGood + setOf(displayId, linuxId))
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = activity.getString(
|
||||
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
|
||||
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
|
||||
@@ -416,15 +295,14 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
setMode(false)
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
private fun useBundle(bundle: DriverBundle.Bundle): String {
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(auto = false, mix = true)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
|
||||
bundle.label,
|
||||
@@ -533,7 +411,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
const val RELEASE_CHECK_INTERVAL_MS = 3_600_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.core.WirelessAdbFix
|
||||
@@ -109,12 +108,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var frameGenLabel by mutableStateOf("")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var pendingSwitch by mutableStateOf<Intent?>(null)
|
||||
private var switchingTo: Intent? = null
|
||||
private var switchPoll: Runnable? = null
|
||||
private var switchDeadline = 0L
|
||||
private var showResetSettings by mutableStateOf(false)
|
||||
private var lowSpace by mutableStateOf<Long?>(null)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
@@ -156,21 +150,15 @@ class MainActivity : ComponentActivity() {
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var picturePosition by mutableStateOf("center")
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
|
||||
private var downloadMirror by mutableStateOf("")
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
|
||||
private var storesView by mutableStateOf(SessionPrefs.STORES_VIEWS.first())
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
@@ -196,10 +184,6 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
com.droiddeck.launcher.handtracking.HandTracking.refreshPermission(this)
|
||||
}
|
||||
private var placementCheck by mutableStateOf<Boolean?>(null)
|
||||
private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
@@ -217,9 +201,6 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickDeckyPluginZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { decky.importPluginZip(it) }
|
||||
}
|
||||
private val pickBootMovie = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importBootMovie(it) }
|
||||
}
|
||||
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
|
||||
}
|
||||
@@ -264,38 +245,6 @@ class MainActivity : ComponentActivity() {
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importBootMovie(file: File) {
|
||||
bootMovieImporting = true
|
||||
bootMovieProgress = -1
|
||||
Thread({
|
||||
val result = com.droiddeck.launcher.session.BootMovie.import(
|
||||
this, file,
|
||||
onStart = { ui.post { android.widget.Toast.makeText(this, getString(R.string.boot_movie_converting_started, file.name), android.widget.Toast.LENGTH_LONG).show() } },
|
||||
onProgress = { percent -> ui.post { bootMovieProgress = percent } },
|
||||
)
|
||||
val message = when (result) {
|
||||
is com.droiddeck.launcher.session.BootMovie.Import.Done -> getString(
|
||||
if (result.audioDropped) R.string.boot_movie_imported_silent else R.string.boot_movie_imported, result.name)
|
||||
com.droiddeck.launcher.session.BootMovie.Import.TooLong ->
|
||||
getString(R.string.boot_movie_too_long, com.droiddeck.launcher.session.BootMovie.MAX_CONVERT_SECONDS)
|
||||
is com.droiddeck.launcher.session.BootMovie.Import.Unsupported -> getString(R.string.boot_movie_unsupported)
|
||||
com.droiddeck.launcher.session.BootMovie.Import.Failed -> getString(R.string.boot_movie_import_failed)
|
||||
}
|
||||
ui.post {
|
||||
bootMovieImporting = false
|
||||
bootMovieProgress = -1
|
||||
loadBootMovie()
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "boot-movie-import").start()
|
||||
}
|
||||
|
||||
private fun loadBootMovie() {
|
||||
bootMovie = com.droiddeck.launcher.session.BootMovie.choice(this)
|
||||
bootMovieSound = SessionPrefs.bootMovieSound(this)
|
||||
bootMovieCustomName = com.droiddeck.launcher.session.BootMovie.customName(this)
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
@@ -372,11 +321,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var bootMovie by mutableStateOf(com.droiddeck.launcher.session.BootMovie.DROIDDECK_30)
|
||||
private var bootMovieSound by mutableStateOf(true)
|
||||
private var bootMovieCustomName by mutableStateOf<String?>(null)
|
||||
private var bootMovieImporting by mutableStateOf(false)
|
||||
private var bootMovieProgress by mutableStateOf(-1)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
@@ -510,186 +454,24 @@ class MainActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, game)) return launchWithoutSteam(game, returnHome)
|
||||
if (SessionState.running) {
|
||||
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
return true
|
||||
}
|
||||
val steamVr = game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID
|
||||
if (!steamVr && !SessionService.launchGame(this, game.gameIdString)) {
|
||||
if (!SessionService.launchGame(this, game.gameIdString)) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
return true
|
||||
}
|
||||
return startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.apply {
|
||||
if (game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID) {
|
||||
putExtra(SessionActivity.EXTRA_STEAMVR, true)
|
||||
} else {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
|
||||
}
|
||||
}, steamSession = true)
|
||||
}
|
||||
|
||||
private fun launchWithoutSteam(game: Library.SteamGame, returnHome: Boolean): Boolean {
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
val added = if (game.library == Library.ADDED) runCatching { com.droiddeck.launcher.session.NoSteamLaunch.addedGame(app, game) }.getOrNull() else null
|
||||
val command = com.droiddeck.launcher.session.NoSteamLaunch.command(game, added)
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
if (command == null) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return@post
|
||||
}
|
||||
com.droiddeck.launcher.session.NoSteamLaunch.remember(command)
|
||||
val next = Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "no-steam-launch").start()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun opensEpicLauncher(next: Intent): Boolean =
|
||||
next.getStringExtra(SessionService.EXTRA_PROGRAM) == com.droiddeck.launcher.session.NoSteamLaunch.EPIC ||
|
||||
next.getStringExtra(SessionService.EXTRA_STEAM_URL) == com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(
|
||||
com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId))
|
||||
|
||||
private fun uninstallEpicLauncher() {
|
||||
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) return
|
||||
com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = true
|
||||
pendingSwitch?.takeIf(::opensEpicLauncher)?.let { pendingSwitch = null }
|
||||
switchingTo?.takeIf(::opensEpicLauncher)?.let {
|
||||
switchPoll?.let { poll -> ui.removeCallbacks(poll) }
|
||||
switchPoll = null
|
||||
switchingTo = null
|
||||
}
|
||||
if (SessionState.running) SessionService.stop(this)
|
||||
val app = applicationContext
|
||||
val deadline = android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS
|
||||
Thread({
|
||||
while ((SessionState.running || SessionState.phase !in SESSION_ENDED) && android.os.SystemClock.uptimeMillis() < deadline) {
|
||||
Thread.sleep(SESSION_SWITCH_POLL_MS)
|
||||
}
|
||||
val ended = !SessionState.running && SessionState.phase in SESSION_ENDED
|
||||
val done = try {
|
||||
ended && com.droiddeck.launcher.stores.epic.EpicClient.uninstall(app)
|
||||
} finally {
|
||||
com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = false
|
||||
}
|
||||
ui.post {
|
||||
if (!isDestroyed) refresh()
|
||||
android.widget.Toast.makeText(app, if (done) R.string.epic_client_uninstalled else R.string.epic_client_uninstall_failed,
|
||||
android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "epic-uninstall").start()
|
||||
}
|
||||
|
||||
private fun openEpicLauncher(): Boolean {
|
||||
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) {
|
||||
android.widget.Toast.makeText(this, R.string.epic_client_uninstalling, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
if (!com.droiddeck.launcher.stores.epic.EpicClient.launcherRunning(this) && !com.droiddeck.launcher.stores.epic.EpicClient.fexReady(this)) {
|
||||
com.droiddeck.launcher.stores.epic.EpicClient.requestFex(this)
|
||||
android.widget.Toast.makeText(this, R.string.epic_client_needs_fex, android.widget.Toast.LENGTH_LONG).show()
|
||||
if (!SessionState.running) startSteamSession()
|
||||
else if (SessionState.mode == SessionService.MODE_STEAM) startActivity(Intent(this, SessionActivity::class.java)
|
||||
.setAction(SessionService.ACTION_RESUME).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
return true
|
||||
}
|
||||
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)) {
|
||||
com.droiddeck.launcher.stores.epic.EpicClient.launcherEntry(this, Library.EPIC_LAUNCHER_NAME)?.let { return launchWithoutSteam(it, false) }
|
||||
}
|
||||
val launcher = Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, false)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(
|
||||
com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)))
|
||||
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested) {
|
||||
val app = applicationContext
|
||||
val shortcut = com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId
|
||||
val listed = com.droiddeck.launcher.frontend.AddedGames.inSteam(app, shortcut)
|
||||
if (listed && !SessionService.launchGame(this, com.droiddeck.launcher.stores.epic.EpicClient.gameId(shortcut))) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, false)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
if (!listed) Thread({
|
||||
com.droiddeck.launcher.frontend.AddedGamesPublisher.republish(app)
|
||||
val id = com.droiddeck.launcher.stores.epic.EpicClient.steamLauncherId(app) ?: shortcut
|
||||
ui.post { SessionService.launchGame(app, com.droiddeck.launcher.stores.epic.EpicClient.gameId(id)) }
|
||||
}, "epic-launcher-open").start()
|
||||
return true
|
||||
}
|
||||
if (SessionState.running) {
|
||||
if (SessionState.stopRequested) android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
else pendingSwitch = launcher
|
||||
return true
|
||||
}
|
||||
return startSessionOrWait(launcher)
|
||||
}
|
||||
|
||||
private fun startSessionOrWait(next: Intent): Boolean {
|
||||
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL))
|
||||
if (!started && (busy || LinuxRuntimeInstaller.isBusy())) android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return started
|
||||
}
|
||||
|
||||
private fun startAfterSession(next: Intent) {
|
||||
SessionService.stop(this)
|
||||
awaitSessionEnd(next, android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS)
|
||||
}
|
||||
|
||||
private fun awaitSessionEnd(next: Intent, deadline: Long) {
|
||||
switchingTo = next
|
||||
switchDeadline = deadline
|
||||
switchPoll?.let { ui.removeCallbacks(it) }
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (isDestroyed) return
|
||||
if (SessionState.running || SessionState.phase !in SESSION_ENDED) {
|
||||
if (android.os.SystemClock.uptimeMillis() < deadline) ui.postDelayed(this, SESSION_SWITCH_POLL_MS)
|
||||
else {
|
||||
switchPoll = null
|
||||
switchingTo = null
|
||||
android.widget.Toast.makeText(this@MainActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
return
|
||||
}
|
||||
switchPoll = null
|
||||
switchingTo = null
|
||||
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling && opensEpicLauncher(next)) return
|
||||
startSessionOrWait(next)
|
||||
}
|
||||
}
|
||||
switchPoll = poll
|
||||
ui.postDelayed(poll, SESSION_SWITCH_POLL_MS)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
|
||||
}
|
||||
|
||||
private fun handleGameLink(games: List<Library.SteamGame>) {
|
||||
@@ -729,17 +511,11 @@ class MainActivity : ComponentActivity() {
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
downloadSource = DownloadSource.mode(this)
|
||||
downloadMirror = DownloadSource.mirror(this)
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
storesShowMature = SessionPrefs.storesShowMature(this)
|
||||
storesSort = SessionPrefs.storesSort(this)
|
||||
storesView = SessionPrefs.storesView(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
@@ -789,9 +565,6 @@ class MainActivity : ComponentActivity() {
|
||||
showPhantomGate = showPhantomGate,
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
downloadSource = downloadSource,
|
||||
downloadMirror = downloadMirror,
|
||||
downloadPrefix = downloadPrefix,
|
||||
animationsEnabled = animationsEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
@@ -800,18 +573,10 @@ class MainActivity : ComponentActivity() {
|
||||
addedGamesArt = addedGamesArt,
|
||||
sgdbUserKey = sgdbUserKey,
|
||||
storesShowMature = storesShowMature,
|
||||
storesSort = storesSort,
|
||||
storesView = storesView,
|
||||
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { playSteam() },
|
||||
onPlaySteamVr = { playSteamVr() },
|
||||
onSteamUrl = { url ->
|
||||
if (!SessionState.running) startSession(
|
||||
Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true,
|
||||
)
|
||||
},
|
||||
onPlay = { startSteamSession() },
|
||||
onPlayDesktopUi = {
|
||||
startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
|
||||
@@ -824,8 +589,6 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
||||
}
|
||||
},
|
||||
onEpicLauncher = { openEpicLauncher() },
|
||||
onUninstallEpicLauncher = { uninstallEpicLauncher() },
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
@@ -834,7 +597,6 @@ class MainActivity : ComponentActivity() {
|
||||
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
|
||||
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
|
||||
},
|
||||
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
|
||||
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onSgdbKey = { key ->
|
||||
@@ -966,26 +728,12 @@ class MainActivity : ComponentActivity() {
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
|
||||
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
|
||||
onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view },
|
||||
onLibraryChanged = { refreshAddedGames(); refresh() },
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
},
|
||||
onDownloadSource = { mode ->
|
||||
DownloadSource.setMode(this, mode)
|
||||
downloadSource = mode
|
||||
},
|
||||
onDownloadMirror = { host ->
|
||||
DownloadSource.setMirror(this, host)
|
||||
downloadMirror = host
|
||||
},
|
||||
onDownloadPrefix = { prefix ->
|
||||
DownloadSource.setCustom(this, prefix)
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
},
|
||||
onHomeApp = { manageHomeApp() },
|
||||
onHomeScreen = { on ->
|
||||
HomeApp.setHomeScreenEnabled(this, on)
|
||||
@@ -1059,7 +807,6 @@ class MainActivity : ComponentActivity() {
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
@@ -1087,15 +834,6 @@ class MainActivity : ComponentActivity() {
|
||||
onDismiss = { showNonAdreno = null },
|
||||
)
|
||||
}
|
||||
pendingSwitch?.let { next ->
|
||||
ConfirmDialog(
|
||||
title = androidx.compose.ui.res.stringResource(R.string.drawer_stop_title),
|
||||
text = runningLabel ?: "",
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.drawer_stop),
|
||||
onConfirm = { pendingSwitch = null; startAfterSession(next) },
|
||||
onDismiss = { pendingSwitch = null },
|
||||
)
|
||||
}
|
||||
if (showRemove) ConfirmDialog(
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
|
||||
@@ -1103,20 +841,6 @@ class MainActivity : ComponentActivity() {
|
||||
onConfirm = { showRemove = false; removeRuntime() },
|
||||
onDismiss = { showRemove = false },
|
||||
)
|
||||
lowSpace?.let { free ->
|
||||
com.droiddeck.launcher.ui.LowSpaceDialog(
|
||||
free,
|
||||
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
|
||||
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
|
||||
onDismiss = { lowSpace = null },
|
||||
)
|
||||
}
|
||||
com.droiddeck.launcher.ui.HandCameraHost(
|
||||
check = placementCheck,
|
||||
onAskPermission = { cameraPermission.launch(Manifest.permission.CAMERA) },
|
||||
onPlay = { placementCheck = null; startSteamSession(steamVr = true) },
|
||||
onClose = { placementCheck = null },
|
||||
)
|
||||
if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog(
|
||||
onConfirm = { gameProfiles -> resetSettings(gameProfiles) },
|
||||
onDismiss = { showResetSettings = false },
|
||||
@@ -1134,10 +858,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
|
||||
else {
|
||||
pendingSwitch = savedIntent(savedInstanceState, KEY_PENDING_SWITCH)
|
||||
savedIntent(savedInstanceState, KEY_SWITCHING_TO)?.let { awaitSessionEnd(it, savedInstanceState.getLong(KEY_SWITCH_DEADLINE)) }
|
||||
}
|
||||
|
||||
val wanted = ArrayList<String>()
|
||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
@@ -1173,21 +893,8 @@ class MainActivity : ComponentActivity() {
|
||||
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
|
||||
}
|
||||
|
||||
private fun playSteam() {
|
||||
val storage = com.droiddeck.launcher.storage.StorageState
|
||||
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
|
||||
lowSpace = storage.freeBytes(this)
|
||||
storage.refresh(this)
|
||||
} else startSteamSession()
|
||||
}
|
||||
|
||||
private fun playSteamVr() {
|
||||
if (com.droiddeck.launcher.handtracking.HandTracking.wanted(this)) placementCheck = true
|
||||
else startSteamSession(steamVr = true)
|
||||
}
|
||||
|
||||
private fun startSteamSession(steamVr: Boolean = false) {
|
||||
startSession(Intent(this, SessionActivity::class.java).putExtra(SessionActivity.EXTRA_STEAMVR, steamVr), steamSession = true)
|
||||
private fun startSteamSession() {
|
||||
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
@@ -1249,7 +956,7 @@ class MainActivity : ComponentActivity() {
|
||||
refreshPhantomStatus()
|
||||
syncLossless()
|
||||
updates.onResume()
|
||||
drivers.checkOnOpen()
|
||||
drivers.ensureAuto(force = false)
|
||||
protons.checkUpdates()
|
||||
if (!SessionState.running) Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
@@ -1284,19 +991,6 @@ class MainActivity : ComponentActivity() {
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) }
|
||||
switchingTo?.let {
|
||||
outState.putParcelable(KEY_SWITCHING_TO, it)
|
||||
outState.putLong(KEY_SWITCH_DEADLINE, switchDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
private fun savedIntent(state: Bundle, key: String): Intent? =
|
||||
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") state.getParcelable(key)
|
||||
|
||||
override fun onDestroy() {
|
||||
updates.unregister()
|
||||
super.onDestroy()
|
||||
@@ -1324,9 +1018,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refreshSecondScreenDisplays() {
|
||||
if (!::displayManager.isInitialized) return
|
||||
// The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
|
||||
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
|
||||
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
|
||||
secondScreenDisplays = SecondScreenDisplays.available(displayManager)
|
||||
}
|
||||
|
||||
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
|
||||
@@ -1460,8 +1152,6 @@ class MainActivity : ComponentActivity() {
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onMode = { m -> drivers.chooseMode(m) },
|
||||
onRestart = { drivers.restartApp() },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
@@ -1556,7 +1246,6 @@ class MainActivity : ComponentActivity() {
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
@@ -1569,11 +1258,6 @@ class MainActivity : ComponentActivity() {
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
bootMovie = if (mode == SessionService.MODE_STEAM) bootMovie else null,
|
||||
bootMovieSound = bootMovieSound,
|
||||
bootMovieCustomName = bootMovieCustomName,
|
||||
bootMovieImporting = bootMovieImporting,
|
||||
bootMovieProgress = bootMovieProgress,
|
||||
wifiDiscovery = if (mode == SessionService.MODE_STEAM) wifiDiscovery else null,
|
||||
wifiDiscoveryPermission = wifiDiscoveryPermission,
|
||||
wifiDiscoveryLocation = wifiDiscoveryLocation,
|
||||
@@ -1622,7 +1306,6 @@ class MainActivity : ComponentActivity() {
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onPicturePosition = { value -> SessionPrefs.setPicturePosition(this, value); picturePosition = value },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1654,12 +1337,6 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
|
||||
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
|
||||
onImportBootMovie = {
|
||||
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
|
||||
},
|
||||
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
@@ -1730,20 +1407,6 @@ class MainActivity : ComponentActivity() {
|
||||
}, "added-games-update").start()
|
||||
}
|
||||
|
||||
private val steamArtRefreshes = HashMap<Int, Int>()
|
||||
|
||||
private fun refreshSteamGameArt(appId: Int) {
|
||||
val version = (steamArtRefreshes[appId] ?: 0) + 1
|
||||
steamArtRefreshes[appId] = version
|
||||
Thread({
|
||||
val fresh = com.droiddeck.launcher.frontend.Library.steamGames(this).firstOrNull { it.appId == appId } ?: return@Thread
|
||||
ui.post {
|
||||
if (steamArtRefreshes[appId] != version) return@post
|
||||
steamGames = steamGames.map { if (it.appId == appId && it.library != Library.ADDED) fresh else it }
|
||||
}
|
||||
}, "steam-art-update").start()
|
||||
}
|
||||
|
||||
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
|
||||
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
|
||||
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
|
||||
@@ -1791,7 +1454,6 @@ class MainActivity : ComponentActivity() {
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
loadBootMovie()
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
@@ -1803,7 +1465,6 @@ class MainActivity : ComponentActivity() {
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
picturePosition = SessionPrefs.picturePosition(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
@@ -1864,7 +1525,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refresh() {
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
if (showComponents) drivers.refreshDrivers()
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
@@ -1884,7 +1544,6 @@ class MainActivity : ComponentActivity() {
|
||||
else -> "Steam"
|
||||
} else null
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
val artRefreshesAtStart = HashMap(steamArtRefreshes)
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
@@ -1913,11 +1572,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
|
||||
steamGames = all.map { g ->
|
||||
val newer = current[g.appId]
|
||||
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
|
||||
}
|
||||
steamGames = all
|
||||
shortcutLibraryScanning = false
|
||||
handleGameLink(all)
|
||||
}
|
||||
@@ -1946,10 +1601,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy()) return false
|
||||
if (com.droiddeck.launcher.storage.StorageState.cleaning) {
|
||||
android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
refreshPhantomStatus()
|
||||
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
showPhantomGate = true
|
||||
@@ -2063,12 +1714,6 @@ class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val MEDIA_SETTLE_MS = 1500L
|
||||
private const val SESSION_SWITCH_WAIT_MS = 30_000L
|
||||
private const val SESSION_SWITCH_POLL_MS = 250L
|
||||
private const val KEY_PENDING_SWITCH = "pending_switch"
|
||||
private const val KEY_SWITCHING_TO = "switching_to"
|
||||
private const val KEY_SWITCH_DEADLINE = "switch_deadline"
|
||||
private val SESSION_ENDED = setOf(SessionPhase.IDLE, SessionPhase.FAILED)
|
||||
private val ZIP_EXT = listOf("zip")
|
||||
private val WCP_EXT = listOf("wcp")
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.NativeActivity
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.gpu.SteamVr
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
class QuestXrActivity : NativeActivity() {
|
||||
private var steamVrStarted = false
|
||||
@Volatile private var closing = false
|
||||
@Volatile private var steamVrStatus = SteamVr.Status.UNAVAILABLE
|
||||
private var sessionId: String? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
sessionId = SessionState.sessionId
|
||||
SessionService.setXrActivityVisible(this, true)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
fun onXrActive(active: Boolean) {
|
||||
if (active) {
|
||||
check(CompositorHost.isStarted) { "DroidDeck compositor is not running" }
|
||||
SessionService.resume(this)
|
||||
WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f, false, 0f)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
WaylandCompositor.nativeSetZeroCopy(false)
|
||||
CompositorHost.attachXr()
|
||||
WaylandCompositor.nativeVsync(System.nanoTime())
|
||||
check(WaylandCompositor.nativeWaitSurface(3000)) { "XR frame GPU handshake timed out" }
|
||||
}
|
||||
SessionService.setXrActivityVisible(this, active)
|
||||
if (active) startSteamVr()
|
||||
}
|
||||
|
||||
private fun startSteamVr() {
|
||||
if (!steamVrStarted) {
|
||||
steamVrStarted = true
|
||||
Thread({
|
||||
try {
|
||||
val started = synchronized(steamVrLock) {
|
||||
if (closing || !sessionAlive()) false else {
|
||||
steamVrOwner = this
|
||||
SteamVr.run(this, "start")
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
if (!closing) leaveXr("SteamVR start failed", sessionAlive())
|
||||
return@Thread
|
||||
}
|
||||
val startupDeadline = SystemClock.elapsedRealtime() + 30_000
|
||||
var lastRunning: Long? = null
|
||||
while (!closing) {
|
||||
if (!sessionAlive()) {
|
||||
leaveXr("Steam session ended", false)
|
||||
break
|
||||
}
|
||||
steamVrStatus = SteamVr.status(this)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (steamVrStatus == SteamVr.Status.RUNNING) lastRunning = now
|
||||
val deadline = lastRunning?.plus(5_000) ?: startupDeadline
|
||||
if (steamVrStatus != SteamVr.Status.RUNNING && now >= deadline) {
|
||||
leaveXr("SteamVR stopped or did not finish starting", true)
|
||||
break
|
||||
}
|
||||
Thread.sleep(1000)
|
||||
}
|
||||
} finally {
|
||||
synchronized(steamVrLock) {
|
||||
// A retiring activity must not stop a replacement's SteamVR session.
|
||||
if (steamVrOwner === this) {
|
||||
if (sessionAlive()) SteamVr.run(this, "stop")
|
||||
steamVrOwner = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "quest-steamvr-start").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionAlive() = SessionState.running && !SessionState.stopRequested &&
|
||||
sessionId != null && SessionState.sessionId == sessionId
|
||||
|
||||
private fun leaveXr(reason: String, failed: Boolean) {
|
||||
Log.w("DroidDeckXR", reason)
|
||||
closing = true
|
||||
runOnUiThread {
|
||||
if (failed) Toast.makeText(this, R.string.cardboard_steamvr_failed, Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun onXrFrame(frameTimeNanos: Long) {
|
||||
WaylandCompositor.nativeVsync(frameTimeNanos)
|
||||
}
|
||||
|
||||
fun onXrStatus(): Int = steamVrStatus.ordinal
|
||||
|
||||
override fun onDestroy() {
|
||||
closing = true
|
||||
SessionService.setXrActivityVisible(this, false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
SessionService.setXrActivityVisible(this, false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val steamVrLock = Any()
|
||||
private var steamVrOwner: QuestXrActivity? = null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -92,75 +92,11 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
|
||||
}
|
||||
"prefs" -> prefs(context, request)
|
||||
"glasses" -> glasses(context, request)
|
||||
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
|
||||
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
}
|
||||
|
||||
private fun glasses(context: Context, request: JSONObject): JSONObject {
|
||||
val g = com.droiddeck.launcher.glasses.Glasses
|
||||
when (request.optString("op", "status")) {
|
||||
"status" -> {}
|
||||
"allow" -> g.requestControl()
|
||||
"set" -> {
|
||||
val key = request.optString("key")
|
||||
val value = request.optString("value")
|
||||
fun on() = value == "1" || value.equals("true", true) || value.equals("on", true)
|
||||
val number = value.toIntOrNull()
|
||||
val numeric = setOf("refreshHz", "sbsHz", "brightness", "volume", "picture3d", "screenWidthDm", "screenDistanceDm", "vrFovDeg", "sbsSupport")
|
||||
if (key in numeric && number == null) return error("INVALID_VALUE", "'$key' takes a number")
|
||||
when (key) {
|
||||
"refreshHz" -> g.setRefreshHz(number!!)
|
||||
"sbsHz" -> g.setSbsHz(number!!)
|
||||
"brightness" -> g.setBrightness(number!!)
|
||||
"volume" -> g.setVolume(number!!)
|
||||
"sunblock" -> g.setSunblock(on())
|
||||
"3d" -> g.set3d(on())
|
||||
"gameOnGlasses" -> g.setGameOnGlasses(on())
|
||||
"headTracking" -> g.setHeadTracking(on())
|
||||
"auto3d" -> g.setAuto3d(on())
|
||||
"picture3d" -> g.setPicture3d(number!!)
|
||||
"screenWidthDm" -> g.setScreenWidthDm(number!!)
|
||||
"screenDistanceDm" -> g.setScreenDistanceDm(number!!)
|
||||
"vrFovDeg" -> g.setVrFovDeg(number!!)
|
||||
"sbsSupport" -> g.setSbsSupport(number!!)
|
||||
else -> return error("INVALID_KEY", "Unknown glasses setting '$key'")
|
||||
}
|
||||
}
|
||||
"simulate" -> {
|
||||
fun angle(key: String) = if (request.has(key)) request.optDouble(key).takeIf { it.isFinite() }?.coerceIn(-90.0, 90.0) else null
|
||||
if (!g.simulate(request.optInt("seconds", 20).coerceIn(1, 600), angle("yaw"), angle("pitch"))) return error("BUSY", "Already simulating")
|
||||
}
|
||||
"stop-simulate" -> g.stopSimulation()
|
||||
"calibrate" -> g.startCalibration()
|
||||
"cancel-calibration" -> g.cancelCalibration()
|
||||
"reset-calibration" -> g.resetCalibration()
|
||||
"query" -> { g.query(); Thread.sleep(600) }
|
||||
"mode" -> {
|
||||
val mode = hex(request.optString("mode"), 0xFF) ?: return error("INVALID_VALUE", "mode must be one hex byte, e.g. 0x31")
|
||||
g.sendMode(mode)
|
||||
Thread.sleep(600)
|
||||
}
|
||||
"imu" -> return g.sampleImu(request.optLong("ms", 2000).coerceIn(200, 20_000)).put("ok", true).put("command", "glasses")
|
||||
"raw" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
val msgId = hex(request.optString("msgId"), 0xFFFF) ?: return error("INVALID_VALUE", "msgId must be a hex number up to 0xFFFF")
|
||||
val digits = request.optString("payload").removePrefix("0x")
|
||||
if (digits.length % 2 != 0 || digits.any { Character.digit(it, 16) < 0 }) return error("INVALID_VALUE", "payload must be whole hex bytes")
|
||||
val payload = digits.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
if (!g.sendRaw(msgId, payload)) return error("NOT_CONNECTED", "The glasses' control link is not open")
|
||||
Thread.sleep(300)
|
||||
}
|
||||
else -> return error("INVALID_OP", "Unknown glasses op")
|
||||
}
|
||||
if (request.optString("op") in setOf("set", "allow")) Thread.sleep(400)
|
||||
return g.status().put("ok", true).put("command", "glasses").put("frames", g.recentFrames())
|
||||
}
|
||||
|
||||
private fun hex(text: String, max: Int): Int? =
|
||||
text.removePrefix("0x").removePrefix("0X").takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.takeIf { it in 0..max }
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
@@ -173,7 +109,6 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("steamUi", SessionState.steamUi ?: JSONObject.NULL)
|
||||
.put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL)
|
||||
.put("suspended", SessionState.suspended)
|
||||
.put("steamVr", SessionState.steamVr)
|
||||
.put("pip", SessionState.pipActive)
|
||||
.put("firstFrame", SessionState.firstFrameSeen)
|
||||
.put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second))
|
||||
|
||||
@@ -136,12 +136,8 @@ object AgentGuest {
|
||||
return null
|
||||
}
|
||||
|
||||
fun steamEval(context: Context, expression: String, timeoutMs: Long): JSONObject =
|
||||
call(context, JSONObject().put("kind", "cdp").put("expression", expression).put("timeout", timeoutMs / 1000.0),
|
||||
timeoutMs + 2_000, trusted = true)
|
||||
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long, trusted: Boolean = false): JSONObject {
|
||||
if (!trusted && request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
|
||||
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
|
||||
val dir = dir(context)
|
||||
|
||||
@@ -97,7 +97,6 @@ class AgentStartActivity : Activity() {
|
||||
request.optString("steamUrl").takeIf { it.startsWith("steam://") }?.let {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL, it)
|
||||
}
|
||||
if (request.optBoolean("steamVr")) putExtra(SessionActivity.EXTRA_STEAMVR, true)
|
||||
}
|
||||
}
|
||||
startActivity(sessionIntent)
|
||||
|
||||
@@ -2,15 +2,10 @@ package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.PerformanceHintManager
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
@@ -37,57 +32,6 @@ object DeviceReport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name of an ApplicationExitInfo reason code, so a report says "LOW_MEMORY" instead of "3". */
|
||||
internal fun exitReasonName(reason: Int): String = when (reason) {
|
||||
0 -> "UNKNOWN"
|
||||
1 -> "EXIT_SELF"
|
||||
2 -> "SIGNALED"
|
||||
3 -> "LOW_MEMORY"
|
||||
4 -> "CRASH"
|
||||
5 -> "CRASH_NATIVE"
|
||||
6 -> "ANR"
|
||||
7 -> "INITIALIZATION_FAILURE"
|
||||
8 -> "PERMISSION_CHANGE"
|
||||
9 -> "EXCESSIVE_RESOURCE_USAGE"
|
||||
10 -> "USER_REQUESTED"
|
||||
11 -> "USER_STOPPED"
|
||||
12 -> "DEPENDENCY_DIED"
|
||||
13 -> "OTHER"
|
||||
14 -> "FREEZER"
|
||||
15 -> "PACKAGE_STATE_CHANGE"
|
||||
16 -> "PACKAGE_UPDATED"
|
||||
else -> "REASON_$reason"
|
||||
}
|
||||
|
||||
internal fun thermalStatusName(status: Int): String = when (status) {
|
||||
0 -> "NONE"
|
||||
1 -> "LIGHT"
|
||||
2 -> "MODERATE"
|
||||
3 -> "SEVERE"
|
||||
4 -> "CRITICAL"
|
||||
5 -> "EMERGENCY"
|
||||
6 -> "SHUTDOWN"
|
||||
else -> "STATUS_$status"
|
||||
}
|
||||
|
||||
internal fun gameModeName(mode: Int): String = when (mode) {
|
||||
0 -> "UNSUPPORTED"
|
||||
1 -> "STANDARD"
|
||||
2 -> "PERFORMANCE"
|
||||
3 -> "BATTERY"
|
||||
4 -> "CUSTOM"
|
||||
else -> "MODE_$mode"
|
||||
}
|
||||
|
||||
internal fun refreshModesSummary(rates: List<Float>): String =
|
||||
rates.map { Math.round(it) }.distinct().sortedDescending().joinToString(", ") { "$it Hz" }
|
||||
|
||||
internal fun exitSummary(processName: String?, reason: Int, status: Int, description: String?): String =
|
||||
buildString {
|
||||
append(processName ?: "?").append(" ").append(exitReasonName(reason)).append(" status=").append(status)
|
||||
if (!description.isNullOrBlank()) append(" ").append(description.trim())
|
||||
}
|
||||
|
||||
fun build(context: Context, mode: String): String {
|
||||
val b = StringBuilder()
|
||||
fun h(title: String) {
|
||||
@@ -124,20 +68,6 @@ object DeviceReport {
|
||||
k("Fingerprint", Build.FINGERPRINT)
|
||||
k("Kernel", System.getProperty("os.version"))
|
||||
k("ABIs", Build.SUPPORTED_ABIS.joinToString(", "))
|
||||
k("Memory page size", runCatching { android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE) }.getOrNull()?.let { "$it bytes" })
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
h("Recent process exits")
|
||||
val exits = runCatching {
|
||||
context.getSystemService(android.app.ActivityManager::class.java).getHistoricalProcessExitReasons(context.packageName, 0, 5)
|
||||
}.getOrNull().orEmpty()
|
||||
if (exits.isEmpty()) k("Exits", "none recorded") else {
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
for (e in exits) {
|
||||
k(stamp.format(Date(e.timestamp)), exitSummary(e.processName, e.reason, e.status, e.description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h("CPU and memory")
|
||||
k("Cores", CpuCores.all.size)
|
||||
@@ -152,19 +82,6 @@ object DeviceReport {
|
||||
k("RAM total", FileUtils.sizeToString(mi.totalMem))
|
||||
k("RAM available", FileUtils.sizeToString(mi.availMem))
|
||||
}
|
||||
runCatching {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) k("Thermal status", thermalStatusName(pm.currentThermalStatus))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
k("Thermal headroom (10 s)", pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) })
|
||||
}
|
||||
k("Power saver", pm.isPowerSaveMode)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val rate = runCatching { context.getSystemService(PerformanceHintManager::class.java)?.preferredUpdateRateNanos }.getOrNull()
|
||||
k("ADPF hint sessions", if (rate == null || rate <= 0) "not supported" else "supported (preferred update ${rate / 1000} us)")
|
||||
k("Game Mode", runCatching { context.getSystemService(android.app.GameManager::class.java)?.gameMode }.getOrNull()?.let { gameModeName(it) })
|
||||
}
|
||||
runCatching {
|
||||
val fs = StatFs(context.filesDir.path)
|
||||
k("App storage free", FileUtils.sizeToString(fs.availableBlocksLong * fs.blockSizeLong))
|
||||
@@ -182,13 +99,6 @@ object DeviceReport {
|
||||
h("Display")
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
runCatching {
|
||||
val display = context.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
|
||||
k("Panel refresh now", String.format(Locale.US, "%.2f Hz", display.refreshRate))
|
||||
k("Panel refresh modes", refreshModesSummary(display.supportedModes.map { it.refreshRate }))
|
||||
}
|
||||
k("peak_refresh_rate", runCatching { Settings.System.getString(context.contentResolver, "peak_refresh_rate") }.getOrNull() ?: "not set")
|
||||
k("refresh_rate_mode", runCatching { Settings.Secure.getString(context.contentResolver, "refresh_rate_mode") }.getOrNull() ?: "not set")
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
|
||||
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object DownloadSource {
|
||||
const val MODE_AUTO = "auto"
|
||||
const val MODE_DIRECT = "direct"
|
||||
const val MODE_MIRROR = "mirror"
|
||||
const val MODE_CUSTOM = "custom"
|
||||
|
||||
private const val PREFS = "download-source"
|
||||
private const val KEY_MODE = "mode"
|
||||
private const val KEY_MIRROR = "mirror"
|
||||
private const val KEY_CUSTOM = "custom"
|
||||
private const val KEY_FASTEST = "fastest"
|
||||
|
||||
private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md"
|
||||
private const val PROBE_TIMEOUT_MS = 4_000
|
||||
private const val PROBE_COOLDOWN_MS = 30_000L
|
||||
private const val UA = "DroidDeck-Android"
|
||||
|
||||
private val MODES = listOf(MODE_AUTO, MODE_DIRECT, MODE_MIRROR, MODE_CUSTOM)
|
||||
|
||||
private val GITHUB_HOSTS = listOf(
|
||||
"github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"api.github.com",
|
||||
)
|
||||
|
||||
private val CATALOGS = listOf(
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/",
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/",
|
||||
)
|
||||
|
||||
data class Mirror(val host: String, val api: Boolean)
|
||||
|
||||
val MIRRORS = listOf(
|
||||
Mirror("gh-proxy.com", true),
|
||||
Mirror("gh-proxy.org", true),
|
||||
Mirror("ghproxy.net", false),
|
||||
Mirror("ghfast.top", false),
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var app: Context? = null
|
||||
|
||||
@Volatile
|
||||
private var lastGood: String? = null
|
||||
|
||||
private val probing = AtomicBoolean(false)
|
||||
private val probedAt = AtomicLong(0L)
|
||||
|
||||
fun init(context: Context) {
|
||||
app = context.applicationContext
|
||||
lastGood = prefs()?.getString(KEY_FASTEST, null)
|
||||
}
|
||||
|
||||
fun probe() {
|
||||
if (app == null || prefs()?.getString(KEY_MODE, MODE_AUTO) == MODE_DIRECT) return
|
||||
if (!probing.compareAndSet(false, true)) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - probedAt.get() < PROBE_COOLDOWN_MS) {
|
||||
probing.set(false)
|
||||
return
|
||||
}
|
||||
probedAt.set(now)
|
||||
Thread {
|
||||
try {
|
||||
val times = ConcurrentHashMap<String, Long>()
|
||||
val sources = listOf<String?>(null) + MIRRORS.map { it.host }
|
||||
val latch = CountDownLatch(sources.size)
|
||||
for (mirror in sources) {
|
||||
Thread {
|
||||
try {
|
||||
val ms = latency(mirror?.let { "https://$it/$PROBE" } ?: PROBE)
|
||||
if (ms > 0) times[mirror ?: ""] = ms
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
latch.await(PROBE_TIMEOUT_MS * 3L, TimeUnit.MILLISECONDS)
|
||||
val best = times.minByOrNull { it.value }?.key ?: return@Thread
|
||||
lastGood = best
|
||||
prefs()?.edit()?.putString(KEY_FASTEST, best)?.apply()
|
||||
} finally {
|
||||
probing.set(false)
|
||||
}
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
private fun latency(url: String): Long {
|
||||
val started = System.nanoTime()
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
val c = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = PROBE_TIMEOUT_MS
|
||||
readTimeout = PROBE_TIMEOUT_MS
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("Range", "bytes=0-1023")
|
||||
setRequestProperty("User-Agent", UA)
|
||||
}
|
||||
connection = c
|
||||
if (c.responseCode / 100 != 2) 0L else {
|
||||
c.inputStream.use { it.read() }
|
||||
(System.nanoTime() - started) / 1_000_000
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
} finally {
|
||||
runCatching { connection?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
fun isGithub(url: String): Boolean = GITHUB_HOSTS.any { url.startsWith("https://$it/") }
|
||||
|
||||
/** Catalogs carry the digests everything else is checked against, so a mirror must never supply one. */
|
||||
fun isCatalog(url: String): Boolean = CATALOGS.any { url.startsWith(it) }
|
||||
|
||||
fun candidates(target: String): List<String> {
|
||||
// GitHub API metadata carries executable URLs and digests; never proxy it.
|
||||
if (isCatalog(target) || target.startsWith("https://api.github.com/")) return listOf(target)
|
||||
val prefs = prefs() ?: return listOf(target)
|
||||
if (!isGithub(target)) return listOf(target)
|
||||
val api = target.startsWith("https://api.github.com/")
|
||||
val proxies = MIRRORS.filter { !api || it.api }.map { it.proxy(target) }
|
||||
val ordered = when (prefs.getString(KEY_MODE, MODE_AUTO) ?: MODE_AUTO) {
|
||||
MODE_DIRECT -> listOf(target)
|
||||
MODE_MIRROR -> listOfNotNull(picked(prefs, api)?.proxy(target)) + listOf(target) + quickest(proxies)
|
||||
MODE_CUSTOM -> listOfNotNull(customProxy(prefs, target)) + listOf(target) + quickest(proxies)
|
||||
else -> if (api) listOf(target) else listOf(target) + quickest(proxies)
|
||||
}
|
||||
return ordered.distinct()
|
||||
}
|
||||
|
||||
private fun quickest(sources: List<String>): List<String> =
|
||||
sources.sortedBy { if (host(it) == lastGood) 0 else 1 }
|
||||
|
||||
fun succeeded(source: String) {
|
||||
lastGood = host(source)
|
||||
}
|
||||
|
||||
fun failed() {
|
||||
lastGood = null
|
||||
prefs()?.edit()?.remove(KEY_FASTEST)?.apply()
|
||||
}
|
||||
|
||||
fun mode(context: Context): String = prefs(context)?.getString(KEY_MODE, MODE_AUTO) ?: MODE_AUTO
|
||||
|
||||
fun setMode(context: Context, mode: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_MODE, mode.takeIf { it in MODES } ?: MODE_AUTO)?.apply()
|
||||
}
|
||||
|
||||
fun mirror(context: Context): String {
|
||||
val host = prefs(context)?.getString(KEY_MIRROR, "") ?: ""
|
||||
return MIRRORS.firstOrNull { it.host == host }?.host ?: MIRRORS.first().host
|
||||
}
|
||||
|
||||
fun setMirror(context: Context, host: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_MIRROR, host.takeIf { h -> MIRRORS.any { it.host == h } } ?: "")?.apply()
|
||||
}
|
||||
|
||||
fun custom(context: Context): String = prefs(context)?.getString(KEY_CUSTOM, "") ?: ""
|
||||
|
||||
fun setCustom(context: Context, prefix: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply()
|
||||
}
|
||||
|
||||
fun reset(context: Context) {
|
||||
lastGood = null
|
||||
prefs(context)?.edit()?.clear()?.apply()
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences? = app?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences? =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun Mirror.proxy(target: String): String = "https://$host/$target"
|
||||
|
||||
private fun host(url: String): String =
|
||||
MIRRORS.firstOrNull { url.startsWith("https://${it.host}/") }?.host ?: ""
|
||||
|
||||
private fun picked(prefs: SharedPreferences, api: Boolean): Mirror? {
|
||||
val chosen = MIRRORS.firstOrNull { it.host == (prefs.getString(KEY_MIRROR, "") ?: "") } ?: MIRRORS.firstOrNull()
|
||||
return if (api && chosen != null && !chosen.api) MIRRORS.firstOrNull { it.api } else chosen
|
||||
}
|
||||
|
||||
private fun custom(prefs: SharedPreferences): String = prefs.getString(KEY_CUSTOM, "") ?: ""
|
||||
|
||||
private fun customProxy(prefs: SharedPreferences, target: String): String? =
|
||||
normalizePrefix(custom(prefs)).takeIf { it.isNotEmpty() }?.let { "$it/$target" }
|
||||
|
||||
private fun normalizePrefix(value: String): String {
|
||||
val trimmed = value.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("http://")) return ""
|
||||
return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed"
|
||||
}
|
||||
}
|
||||
@@ -6,201 +6,63 @@ import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
public final class Downloader {
|
||||
private static final String TAG = "Downloader";
|
||||
private static final int CONNECT_TIMEOUT_MS = 10_000;
|
||||
private static final int STALL_TIMEOUT_MS = 15_000;
|
||||
private static final int HTTP_RANGE_NOT_SATISFIABLE = 416;
|
||||
private static final int TIMEOUT_MS = 30_000;
|
||||
|
||||
private Downloader() {}
|
||||
|
||||
public static String downloadString(String url) {
|
||||
List<String> sources = DownloadSource.INSTANCE.candidates(url);
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
|
||||
Fetch fetch = fetch(sources.get(i));
|
||||
if (fetch.body != null) {
|
||||
DownloadSource.INSTANCE.succeeded(sources.get(i));
|
||||
return fetch.body;
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = open(url);
|
||||
if (connection.getResponseCode() / 100 != 2) {
|
||||
Log.w(TAG, url + " -> HTTP " + connection.getResponseCode());
|
||||
return null;
|
||||
}
|
||||
if (fetch.fatal && sources.get(i).equals(url)) return null;
|
||||
try (InputStream in = connection.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
FileUtils.copy(in, out);
|
||||
return new String(out.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "GET " + url, e);
|
||||
return null;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
DownloadSource.INSTANCE.failed();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fetches without the mirror list, for a URL whose body is a digest the payload is checked against. */
|
||||
public static String downloadDirect(String url) {
|
||||
return fetch(url).body;
|
||||
}
|
||||
|
||||
public static boolean downloadFile(String url, File destination, boolean resume,
|
||||
java.util.function.Consumer<Float> progress) {
|
||||
return downloadFile(url, destination, resume, null, progress);
|
||||
}
|
||||
|
||||
/** Verify each candidate before accepting it, so a bad mirror can fall back. */
|
||||
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
|
||||
java.util.function.Consumer<Float> progress) {
|
||||
if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}"))
|
||||
throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters");
|
||||
return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress);
|
||||
}
|
||||
|
||||
// Package-private for the local multi-source HTTP test.
|
||||
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
|
||||
String expectedSha256, java.util.function.Consumer<Float> progress) {
|
||||
Identity identity = loadIdentity(destination, resume, expectedSha256);
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
String source = sources.get(i);
|
||||
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
|
||||
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, progress);
|
||||
if (outcome == Outcome.DONE) {
|
||||
if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(Hashes.sha256(destination))) {
|
||||
Log.w(TAG, "checksum mismatch from " + source + "; trying another source");
|
||||
if (!discardPartial(destination, identity)) return false;
|
||||
continue;
|
||||
}
|
||||
resumeFile(destination).delete();
|
||||
DownloadSource.INSTANCE.succeeded(source);
|
||||
if (progress != null) progress.accept(1f);
|
||||
return true;
|
||||
}
|
||||
if (outcome == Outcome.FATAL && source.equals(original)) return false;
|
||||
}
|
||||
DownloadSource.INSTANCE.failed();
|
||||
return false;
|
||||
}
|
||||
|
||||
private enum Outcome { DONE, RETRY, FATAL }
|
||||
|
||||
private static final class Identity {
|
||||
private String source;
|
||||
private String etag;
|
||||
}
|
||||
|
||||
private static File resumeFile(File dest) {
|
||||
return new File(dest.getPath() + ".resume.properties");
|
||||
}
|
||||
|
||||
private static boolean strongETag(String value) {
|
||||
return value != null && !value.isEmpty() && !value.startsWith("W/");
|
||||
}
|
||||
|
||||
/** Only reuse saved bytes when we persisted a strong validator and their source. */
|
||||
private static Identity loadIdentity(File dest, boolean resume, String expectedSha256) {
|
||||
Identity identity = new Identity();
|
||||
File marker = resumeFile(dest);
|
||||
if (!resume || !dest.isFile() || dest.length() == 0) {
|
||||
marker.delete();
|
||||
return identity;
|
||||
}
|
||||
try (FileInputStream in = new FileInputStream(marker)) {
|
||||
Properties p = new Properties();
|
||||
p.load(in);
|
||||
String source = p.getProperty("source");
|
||||
String etag = p.getProperty("etag");
|
||||
String expected = p.getProperty("sha256", "");
|
||||
if (source != null && source.startsWith("http") && strongETag(etag)
|
||||
&& expected.equals(expectedSha256 == null ? "" : expectedSha256.toLowerCase(java.util.Locale.ROOT))) {
|
||||
identity.source = source;
|
||||
identity.etag = etag;
|
||||
return identity;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Legacy partials or interrupted metadata writes have no trustworthy identity.
|
||||
}
|
||||
if (!discardPartial(dest, identity)) Log.w(TAG, "could not discard unidentified partial " + dest);
|
||||
return identity;
|
||||
}
|
||||
|
||||
private static boolean discardPartial(File dest, Identity identity) {
|
||||
identity.source = null;
|
||||
identity.etag = null;
|
||||
File marker = resumeFile(dest);
|
||||
return (!dest.exists() || dest.delete()) && (!marker.exists() || marker.delete());
|
||||
}
|
||||
|
||||
private static void saveIdentity(File dest, Identity identity, String expectedSha256) {
|
||||
File marker = resumeFile(dest);
|
||||
if (!strongETag(identity.etag)) {
|
||||
marker.delete();
|
||||
return;
|
||||
}
|
||||
Properties p = new Properties();
|
||||
p.setProperty("source", identity.source);
|
||||
p.setProperty("etag", identity.etag);
|
||||
p.setProperty("sha256", expectedSha256 == null ? "" : expectedSha256.toLowerCase(java.util.Locale.ROOT));
|
||||
try (FileOutputStream out = new FileOutputStream(marker)) {
|
||||
p.store(out, "DroidDeck partial download validator");
|
||||
} catch (IOException e) {
|
||||
marker.delete(); // No valid metadata means no future resume.
|
||||
Log.w(TAG, "cannot persist download validator", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Outcome attempt(String url, File destination, boolean resume, Identity identity,
|
||||
String expectedSha256, java.util.function.Consumer<Float> progress) {
|
||||
HttpURLConnection connection = null;
|
||||
long have = resume && destination.isFile() ? destination.length() : 0;
|
||||
try {
|
||||
if (have > 0 && (!url.equals(identity.source) || !strongETag(identity.etag))) {
|
||||
// A new source or unvalidated bytes must restart from byte zero.
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
have = 0;
|
||||
}
|
||||
connection = open(url, STALL_TIMEOUT_MS);
|
||||
if (have > 0) {
|
||||
connection.setRequestProperty("Range", "bytes=" + have + "-");
|
||||
connection.setRequestProperty("If-Range", identity.etag);
|
||||
}
|
||||
connection = open(url);
|
||||
if (have > 0) connection.setRequestProperty("Range", "bytes=" + have + "-");
|
||||
int code = connection.getResponseCode();
|
||||
if (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) {
|
||||
connection.disconnect();
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
return attempt(url, destination, false, identity, expectedSha256, progress);
|
||||
}
|
||||
if (code / 100 != 2) {
|
||||
Log.w(TAG, url + " -> HTTP " + code);
|
||||
return code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE
|
||||
? Outcome.FATAL : Outcome.RETRY;
|
||||
}
|
||||
boolean appending = code == HttpURLConnection.HTTP_PARTIAL;
|
||||
if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) {
|
||||
connection.disconnect();
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
return attempt(url, destination, false, identity, expectedSha256, progress);
|
||||
}
|
||||
String etag = connection.getHeaderField("ETag");
|
||||
if (have > 0 && appending && (!strongETag(etag) || !etag.equals(identity.etag))) {
|
||||
connection.disconnect();
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
return attempt(url, destination, false, identity, expectedSha256, progress);
|
||||
}
|
||||
if (!appending && have > 0) {
|
||||
// If-Range may legitimately return full 200 when representation changed.
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
have = 0;
|
||||
if (code / 100 != 2) {
|
||||
if (have > 0) {
|
||||
connection.disconnect();
|
||||
destination.delete();
|
||||
return downloadFile(url, destination, false, progress);
|
||||
}
|
||||
Log.w(TAG, url + " -> HTTP " + code);
|
||||
return false;
|
||||
}
|
||||
if (!appending) have = 0;
|
||||
long length = connection.getContentLengthLong();
|
||||
long total = appending ? rangeTotal(connection.getHeaderField("Content-Range")) : -1;
|
||||
if (total < 0) total = length < 0 ? -1 : length + have;
|
||||
long total = length < 0 ? -1 : length + have;
|
||||
File parent = destination.getParentFile();
|
||||
if (parent != null && !parent.isDirectory()) parent.mkdirs();
|
||||
identity.source = url;
|
||||
identity.etag = strongETag(etag) ? etag : null;
|
||||
saveIdentity(destination, identity, expectedSha256);
|
||||
if (parent != null && !parent.isDirectory())
|
||||
parent.mkdirs();
|
||||
long written = have;
|
||||
try (InputStream in = new BufferedInputStream(connection.getInputStream(), 1 << 16);
|
||||
OutputStream out = new FileOutputStream(destination, appending)) {
|
||||
@@ -215,66 +77,20 @@ public final class Downloader {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && written < total) return Outcome.RETRY;
|
||||
return Outcome.DONE;
|
||||
if (progress != null) progress.accept(1f);
|
||||
return total <= 0 || written >= total;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "download " + url, e);
|
||||
return Outcome.RETRY;
|
||||
return false;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a `Content-Range: bytes START-END/TOTAL` header starts exactly at {@code start}. */
|
||||
static boolean rangeStartsAt(String contentRange, long start) {
|
||||
if (contentRange == null) return false;
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+(\\d+)-\\d+/(?:\\d+|\\*)\\s*$").matcher(contentRange);
|
||||
return m.matches() && Long.parseLong(m.group(1)) == start;
|
||||
}
|
||||
|
||||
/** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
|
||||
static long rangeTotal(String contentRange) {
|
||||
if (contentRange == null) return -1;
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange);
|
||||
return m.matches() ? Long.parseLong(m.group(1)) : -1;
|
||||
}
|
||||
|
||||
private static final class Fetch {
|
||||
final String body;
|
||||
final boolean fatal;
|
||||
|
||||
Fetch(String body, boolean fatal) {
|
||||
this.body = body;
|
||||
this.fatal = fatal;
|
||||
}
|
||||
}
|
||||
|
||||
private static Fetch fetch(String url) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = open(url, STALL_TIMEOUT_MS);
|
||||
int code = connection.getResponseCode();
|
||||
if (code / 100 != 2) {
|
||||
Log.w(TAG, url + " -> HTTP " + code);
|
||||
return new Fetch(null, code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE);
|
||||
}
|
||||
try (InputStream in = connection.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
FileUtils.copy(in, out);
|
||||
return new Fetch(new String(out.toByteArray(), StandardCharsets.UTF_8), false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "GET " + url, e);
|
||||
return new Fetch(null, false);
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
|
||||
private static HttpURLConnection open(String url) throws java.io.IOException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(readTimeoutMs);
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
connection.setInstanceFollowRedirects(true);
|
||||
connection.setRequestProperty("User-Agent", "DroidDeck-Android");
|
||||
return connection;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
object GameEnvironment {
|
||||
data class Config(
|
||||
val shared: Map<String, String?> = emptyMap(),
|
||||
@@ -85,34 +83,4 @@ object GameEnvironment {
|
||||
}
|
||||
|
||||
const val CUSTOM_FEX_PRESET = "__custom__"
|
||||
|
||||
val syncBackends = listOf(SessionPrefs.SYNC_NTSYNC, SessionPrefs.SYNC_FSYNC, SessionPrefs.SYNC_ESYNC, SessionPrefs.SYNC_WINESERVER)
|
||||
|
||||
val syncKeys = listOf("BL_SYNC", "BL_FSYNC_FIRST", "BL_SYNC_FALLBACK", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
|
||||
|
||||
fun syncEnvironment(backend: String): Map<String, String> {
|
||||
require(backend in syncBackends) { "unknown sync $backend" }
|
||||
return buildMap {
|
||||
put("BL_SYNC", if (backend == SessionPrefs.SYNC_NTSYNC) "1" else "0")
|
||||
put("BL_FSYNC_FIRST", if (backend == SessionPrefs.SYNC_FSYNC) "1" else "0")
|
||||
put("BL_SYNC_FALLBACK", if (backend == SessionPrefs.SYNC_WINESERVER) "0" else "1")
|
||||
if (backend == SessionPrefs.SYNC_FSYNC) put("PROTON_NO_ESYNC", "1")
|
||||
}
|
||||
}
|
||||
|
||||
fun gameSync(config: Config, scope: String): String? {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
val entries = config.games[scope].orEmpty().filterKeys { it in syncKeys }
|
||||
if (entries.isEmpty()) return null
|
||||
return syncBackends.firstOrNull { entries == syncEnvironment(it) } ?: CUSTOM_SYNC
|
||||
}
|
||||
|
||||
fun withGameSync(config: Config, scope: String, backend: String?): Config {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
val entries = config.entries(scope).filterKeys { it !in syncKeys }.toMutableMap()
|
||||
if (backend != null) entries += syncEnvironment(backend)
|
||||
return config.withEntries(scope, entries)
|
||||
}
|
||||
|
||||
const val CUSTOM_SYNC = "__custom__"
|
||||
}
|
||||
@@ -26,12 +26,10 @@ object PhantomProcessLimit {
|
||||
usesDeviceConfig(sdk) && enabled -> listOf(
|
||||
"device_config delete activity_manager max_phantom_processes",
|
||||
"device_config set_sync_disabled_for_tests none",
|
||||
"settings put global $SETTING true",
|
||||
)
|
||||
usesDeviceConfig(sdk) -> listOf(
|
||||
"device_config set_sync_disabled_for_tests persistent",
|
||||
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
|
||||
"settings put global $SETTING false",
|
||||
)
|
||||
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
|
||||
}
|
||||
@@ -59,12 +57,7 @@ object PhantomProcessLimit {
|
||||
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
|
||||
if (usesDeviceConfig(sdk)) {
|
||||
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
|
||||
val global = try {
|
||||
Settings.Global.getString(context.contentResolver, SETTING)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
return android12Status(off, global, systemProperty(OVERRIDE_PROPERTY))
|
||||
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
|
||||
}
|
||||
val global = try {
|
||||
Settings.Global.getString(context.contentResolver, SETTING)
|
||||
@@ -74,10 +67,6 @@ object PhantomProcessLimit {
|
||||
return status(global, systemProperty(OVERRIDE_PROPERTY))
|
||||
}
|
||||
|
||||
fun android12Status(rememberedOff: Boolean, global: String?, override: String?): PhantomProcessStatus =
|
||||
if (rememberedOff || status(global, override) == PhantomProcessStatus.DISABLED) PhantomProcessStatus.DISABLED
|
||||
else PhantomProcessStatus.UNREADABLE
|
||||
|
||||
fun status(global: String?, override: String?): PhantomProcessStatus {
|
||||
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.os.Process
|
||||
import android.util.Log
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object SessionLogCapture {
|
||||
private const val TAG = "SessionLogCapture"
|
||||
@@ -12,7 +11,6 @@ object SessionLogCapture {
|
||||
private var pid = -1
|
||||
private var writer: BufferedWriter? = null
|
||||
private var target: File? = null
|
||||
private var syncer: Thread? = null
|
||||
|
||||
@Synchronized
|
||||
fun start(target: File) {
|
||||
@@ -39,7 +37,6 @@ object SessionLogCapture {
|
||||
out.write("logcat could not be started; this ROM may not hand an app its own entries.\n")
|
||||
out.flush()
|
||||
}
|
||||
target.parentFile?.let { syncer = startSyncing(it) }
|
||||
Log.i(TAG, "app log -> $target (logcat pid $pid)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not start the app log", e)
|
||||
@@ -52,19 +49,8 @@ object SessionLogCapture {
|
||||
if (t.parentFile?.absolutePath == dir.absolutePath) stop()
|
||||
}
|
||||
|
||||
/** flush() only reaches the page cache, and a hard reset on f2fs lost the last minutes of every
|
||||
* log. Every 2 s the session folder's files (ours and the native parts') are synced to storage. */
|
||||
private fun startSyncing(dir: File): Thread = Thread({
|
||||
while (true) {
|
||||
try { Thread.sleep(2000) } catch (e: InterruptedException) { break }
|
||||
dir.listFiles()?.forEach { runCatching { FileOutputStream(it, true).use { s -> s.fd.sync() } } }
|
||||
}
|
||||
}, "session-log-sync").apply { isDaemon = true; start() }
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
syncer?.interrupt()
|
||||
syncer = null
|
||||
target = null
|
||||
if (pid != -1) {
|
||||
try {
|
||||
|
||||
@@ -22,7 +22,7 @@ object AddedGameArt {
|
||||
}
|
||||
|
||||
enum class Slot(val key: String, val cacheName: String, val assetType: Int?) {
|
||||
COVER("p", "p.jpg", 0), WIDE("header", "header.jpg", 3), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
|
||||
COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
|
||||
}
|
||||
|
||||
const val FOLDER = "folder"
|
||||
@@ -36,14 +36,10 @@ object AddedGameArt {
|
||||
|
||||
class Option(val group: String, val thumb: String, val full: String)
|
||||
|
||||
class Choices(val options: List<Option>, val sgdb: SteamGridDb.Status)
|
||||
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
|
||||
|
||||
class Found<T>(val items: List<T>, val status: SteamGridDb.Status)
|
||||
|
||||
private val CHOSEN = Regex("""chosen-(p|header|hero|logo|icon)(?:-\d+)?\.(\w+)""")
|
||||
|
||||
internal fun chosen(cache: File, slot: Slot): File? =
|
||||
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.maxWithOrNull(compareBy({ it.lastModified() }, { it.name }))
|
||||
private fun chosen(cache: File, slot: Slot): File? =
|
||||
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
|
||||
|
||||
private val IMAGE = listOf("png", "jpg", "jpeg")
|
||||
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
|
||||
@@ -79,7 +75,7 @@ object AddedGameArt {
|
||||
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
|
||||
return Art(
|
||||
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
|
||||
header = chosen(cache, Slot.WIDE) ?: own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
header = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
|
||||
logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"),
|
||||
icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game),
|
||||
@@ -96,7 +92,6 @@ object AddedGameArt {
|
||||
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
|
||||
val auto: Pair<File, String>? = when (slot) {
|
||||
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
|
||||
Slot.WIDE -> own.header?.let { it to FOLDER } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
|
||||
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
|
||||
Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png")
|
||||
Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB }
|
||||
@@ -113,86 +108,40 @@ object AddedGameArt {
|
||||
dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty()
|
||||
}.take(12)
|
||||
|
||||
fun choices(context: Context, game: AddedGames.Game, slot: Slot): Choices {
|
||||
fun options(context: Context, game: AddedGames.Game, slot: Slot): List<Option> {
|
||||
val out = ArrayList<Option>()
|
||||
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
|
||||
val cache = cacheDir(context, game)
|
||||
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
|
||||
if (steamId != null) steamOption(steamId, slot)?.let { out.add(it) }
|
||||
val (found, status) = sgdbChoices(context, File(cache, "sgdb-id"), steamId?.toIntOrNull(), game.name, slot)
|
||||
out.addAll(found)
|
||||
return Choices(out, status)
|
||||
}
|
||||
|
||||
internal fun steamOption(steamId: String, slot: Slot): Option? {
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.WIDE -> "header.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null } ?: return null
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote) ?: return null
|
||||
return Option(STEAM, url, url)
|
||||
}
|
||||
|
||||
internal fun sgdbChoices(context: Context, marker: File, steamId: Int?, name: String, slot: Slot): Pair<List<Option>, SteamGridDb.Status> {
|
||||
val key = SteamGridDb.key(context)
|
||||
if (key.isEmpty()) return emptyList<Option>() to SteamGridDb.Status.NO_KEY
|
||||
marker.parentFile?.mkdirs()
|
||||
return try {
|
||||
val gridId = remembered(marker, SteamGridDb.keyChangedAt(context), retryMiss = true) {
|
||||
SteamGridDb.gameId(key, steamId, name)?.toString()
|
||||
}?.toIntOrNull() ?: return emptyList<Option>() to SteamGridDb.Status.NO_GAME
|
||||
SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5).map { (thumb, full) -> Option(SGDB, thumb, full) } to SteamGridDb.Status.OK
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$name: SteamGridDB failed (${e.javaClass.simpleName})")
|
||||
emptyList<Option>() to ((e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER)
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
|
||||
if (steamId != null && remote != null) {
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
|
||||
if (url != null) out.add(Option(STEAM, url, url))
|
||||
}
|
||||
}
|
||||
|
||||
fun searchGames(context: Context, term: String): Found<SteamGridDb.Game> = sgdb(context) { SteamGridDb.search(it, term) }
|
||||
|
||||
fun gameImages(context: Context, id: Int, slot: Slot): Found<Option> = sgdb(context) { key ->
|
||||
SteamGridDb.all(key, id, slot).map { (thumb, full) -> Option(SGDB, thumb, full) }
|
||||
}
|
||||
|
||||
private fun <T> sgdb(context: Context, call: (String) -> List<T>): Found<T> {
|
||||
val key = SteamGridDb.key(context)
|
||||
if (key.isEmpty()) return Found(emptyList(), SteamGridDb.Status.NO_KEY)
|
||||
return try {
|
||||
Found(call(key), SteamGridDb.Status.OK)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "SteamGridDB: ${e.javaClass.simpleName}")
|
||||
Found(emptyList(), (e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER)
|
||||
if (key.isNotEmpty()) {
|
||||
cache.mkdirs()
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull()
|
||||
if (gridId != null) runCatching { SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5) }.getOrNull()
|
||||
?.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
|
||||
if (!store(cacheDir(context, game), slot, option)) return false
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun store(cache: File, slot: Slot, option: Option): Boolean {
|
||||
val fresh = storeNew(cache, slot, option) ?: return false
|
||||
dropOlder(cache, slot, fresh)
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun dropOlder(cache: File, slot: Slot, keep: File) {
|
||||
cache.listFiles { f -> f != keep && CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private val lastStamp = java.util.concurrent.atomic.AtomicLong(0)
|
||||
|
||||
private fun nextStamp(): Long = lastStamp.updateAndGet { maxOf(it + 1, System.currentTimeMillis()) }
|
||||
|
||||
internal fun storeNew(cache: File, slot: Slot, option: Option): File? {
|
||||
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
|
||||
bytes ?: return null
|
||||
bytes ?: return false
|
||||
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
|
||||
cache.mkdirs()
|
||||
val suffix = if (ext == "jpeg") "jpg" else ext
|
||||
var dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
|
||||
while (dst.exists()) dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
|
||||
val cache = cacheDir(context, game).apply { mkdirs() }
|
||||
chosen(cache, slot)?.delete()
|
||||
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
|
||||
val tmp = File(cache, dst.name + ".tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
return if (tmp.renameTo(dst)) dst else null
|
||||
if (!tmp.renameTo(dst)) return false
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
|
||||
return true
|
||||
}
|
||||
|
||||
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
|
||||
@@ -231,8 +180,6 @@ object AddedGameArt {
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
|
||||
if (!SessionPrefs.addedGamesArt(context)) return false
|
||||
val sgdbKey = SteamGridDb.key(context)
|
||||
val keyChangedAt = SteamGridDb.keyChangedAt(context)
|
||||
var sgdbDown = false
|
||||
var changed = false
|
||||
for (game in games) {
|
||||
if (local(game).portrait != null) continue
|
||||
@@ -245,24 +192,11 @@ object AddedGameArt {
|
||||
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
|
||||
val id = match.appId?.toString()
|
||||
if (id != null && download(id, cache)) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty() || sgdbDown) continue
|
||||
val stored: String? = try {
|
||||
remembered(File(cache, "sgdb-id"), keyChangedAt) {
|
||||
SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass")
|
||||
sgdbDown = true
|
||||
null
|
||||
}
|
||||
val gridId = stored?.toIntOrNull() ?: continue
|
||||
val grabbed = try {
|
||||
SteamGridDb.download(sgdbKey, gridId, cache)
|
||||
} catch (e: Exception) {
|
||||
sgdbDown = true
|
||||
false
|
||||
}
|
||||
if (grabbed) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull() ?: continue
|
||||
if (runCatching { SteamGridDb.download(sgdbKey, gridId, cache) }.getOrDefault(false)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -273,12 +207,11 @@ object AddedGameArt {
|
||||
return match
|
||||
}
|
||||
|
||||
internal fun remembered(marker: File, notBefore: Long = 0L, retryMiss: Boolean = false, find: () -> String?): String? {
|
||||
private fun remembered(marker: File, find: () -> String?): String? {
|
||||
if (marker.isFile) {
|
||||
val value = marker.readText().trim()
|
||||
if (value != "none") return value
|
||||
val at = marker.lastModified()
|
||||
if (!retryMiss && at >= notBefore && System.currentTimeMillis() - at < RETRY_AFTER_MS) return null
|
||||
if (System.currentTimeMillis() - marker.lastModified() < RETRY_AFTER_MS) return null
|
||||
}
|
||||
val found = find()
|
||||
marker.writeText(found ?: "none")
|
||||
|
||||
@@ -36,34 +36,19 @@ object AddedGameEdits {
|
||||
|
||||
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
|
||||
val game = game(context, folder) ?: return false
|
||||
return synchronized(SteamGrid.lock) {
|
||||
if (!AddedGameArt.choose(context, game, slot, option)) return@synchronized false
|
||||
applyArt(context, game, slot)
|
||||
true
|
||||
}
|
||||
if (!AddedGameArt.choose(context, game, slot, option)) return false
|
||||
applyArt(context, game, slot)
|
||||
return true
|
||||
}
|
||||
|
||||
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
|
||||
val game = game(context, folder) ?: return
|
||||
synchronized(SteamGrid.lock) {
|
||||
AddedGameArt.reset(context, game, slot)
|
||||
applyArt(context, game, slot)
|
||||
}
|
||||
}
|
||||
|
||||
fun logoPosition(context: Context, folder: String): LogoPosition.Value? =
|
||||
game(context, folder)?.let { SteamGrid.position(SteamGrid.target(it, context)) }
|
||||
|
||||
fun setLogoPosition(context: Context, folder: String, value: LogoPosition.Value?): Boolean {
|
||||
val game = game(context, folder) ?: return false
|
||||
if (!SteamGrid.writePosition(context, SteamGrid.target(game, context), value)) return false
|
||||
publish(context) { SteamLiveShortcuts.setLogoPosition(context, game.appId, value) }
|
||||
return true
|
||||
AddedGameArt.reset(context, game, slot)
|
||||
applyArt(context, game, slot)
|
||||
}
|
||||
|
||||
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
|
||||
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
|
||||
if (slot != AddedGameArt.Slot.ICON) SteamGrid.write(context, SteamGrid.target(game, context), slot, AddedGameArt.chosen(AddedGameArt.cacheDir(context, game), slot))
|
||||
publish(context) {
|
||||
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
|
||||
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
@@ -297,16 +296,13 @@ object AddedGames {
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
|
||||
run {
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } }
|
||||
val ranking = rank(folder)
|
||||
val candidates = ranking.exes
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile }
|
||||
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
val epicLauncher = sidecar?.takeIf { it.store == Store.EPIC && (chosen == null || canonical(chosen) == canonical(it.exeFile(folder))) }
|
||||
?.launcherFile(folder)?.takeIf { it.isFile }
|
||||
val launch = epicLauncher ?: if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val foundGuest = guestPath(context, launch)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
|
||||
@@ -334,7 +330,7 @@ object AddedGames {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun accountConfig(root: File): File? {
|
||||
private fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
@@ -347,9 +343,6 @@ object AddedGames {
|
||||
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
fun inSteam(context: Context, appId: Long): Boolean =
|
||||
accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))?.let { shortcutRecord(it, appId) } != null
|
||||
|
||||
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||
}.getOrNull()
|
||||
@@ -389,8 +382,7 @@ object AddedGames {
|
||||
}
|
||||
}
|
||||
|
||||
fun writeListing(context: Context, games: List<Game>,
|
||||
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File {
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
val json = StringBuilder("[")
|
||||
games.forEachIndexed { i, g ->
|
||||
@@ -407,10 +399,6 @@ object AddedGames {
|
||||
if (pieces.isNotEmpty()) json.append(",\"art\":{").append(pieces.joinToString(",") { (k, v) -> quote(k) + ":" + quote(v) }).append('}')
|
||||
json.append('}')
|
||||
}
|
||||
extra.forEachIndexed { i, entry ->
|
||||
if (games.isNotEmpty() || i > 0) json.append(',')
|
||||
json.append(entry.toString())
|
||||
}
|
||||
file.writeText(json.append(']').toString())
|
||||
return file
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ object AutoComponents {
|
||||
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
fun constrained(context: Context): Boolean {
|
||||
private fun constrained(context: Context): Boolean {
|
||||
val power = context.getSystemService(PowerManager::class.java)
|
||||
if (power?.isPowerSaveMode == true) return true
|
||||
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -200,62 +199,24 @@ object CoverArt {
|
||||
setRequestProperty("User-Agent", "DroidDeck")
|
||||
}
|
||||
|
||||
private fun <T> through(url: String, attempt: (String, Boolean) -> T?): T? {
|
||||
for (source in DownloadSource.candidates(url)) {
|
||||
val result = attempt(source, source == url)
|
||||
if (result != null) {
|
||||
DownloadSource.succeeded(source)
|
||||
return result
|
||||
}
|
||||
}
|
||||
DownloadSource.failed()
|
||||
return null
|
||||
}
|
||||
private fun exists(url: String): Boolean = try {
|
||||
val c = open(url, "HEAD"); val ok = c.responseCode == 200; c.disconnect(); ok
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
private fun exists(url: String): Boolean = through(url) { source, original -> head(source, original) } ?: false
|
||||
|
||||
private fun head(url: String, original: Boolean): Boolean? = try {
|
||||
val c = open(url, "HEAD")
|
||||
val code = c.responseCode
|
||||
c.disconnect()
|
||||
when {
|
||||
code == 200 -> true
|
||||
code == 404 || code == 410 -> if (original) false else null
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
private fun get(url: String): String? = through(url) { source, original -> text(source, original) }
|
||||
|
||||
private fun text(url: String, original: Boolean): String? = try {
|
||||
private fun get(url: String): String? = try {
|
||||
val c = open(url)
|
||||
val code = c.responseCode
|
||||
when {
|
||||
code == 200 -> c.inputStream.bufferedReader().use { it.readText() }.takeIf { it.isNotEmpty() }
|
||||
code == 404 || code == 410 -> { c.disconnect(); if (original) "" else null }
|
||||
else -> { c.disconnect(); null }
|
||||
}
|
||||
if (c.responseCode != 200) { c.disconnect(); null } else c.inputStream.bufferedReader().use { it.readText() }
|
||||
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
|
||||
|
||||
private fun download(url: String, target: File): Boolean = through(url) { source, original -> fetch(source, target, original) } ?: false
|
||||
|
||||
private fun fetch(url: String, target: File, original: Boolean): Boolean? = try {
|
||||
private fun download(url: String, target: File): Boolean = try {
|
||||
val c = open(url)
|
||||
when (val code = c.responseCode) {
|
||||
200 -> {
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".part")
|
||||
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(tmp.path, bounds)
|
||||
when {
|
||||
bounds.outWidth > 0 && tmp.renameTo(target) -> true
|
||||
original -> { tmp.delete(); false }
|
||||
else -> { tmp.delete(); null }
|
||||
}
|
||||
}
|
||||
404, 410 -> { c.disconnect(); if (original) false else null }
|
||||
else -> { c.disconnect(); null }
|
||||
if (c.responseCode != 200) { c.disconnect(); false } else {
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".part")
|
||||
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(tmp.path, bounds)
|
||||
if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false }
|
||||
}
|
||||
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
|
||||
} catch (e: Exception) { Log.w(TAG, "$url: $e"); false }
|
||||
}
|
||||
@@ -76,7 +76,7 @@ object DependencyDetector {
|
||||
|
||||
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
|
||||
val name = f.name.lowercase(Locale.US)
|
||||
val path = relativeReason(gameDir, f).lowercase(Locale.US)
|
||||
val path = f.path.replace('\\', '/').lowercase(Locale.US)
|
||||
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
|
||||
Kind.BUNDLED else Kind.SHIPPED
|
||||
for (comp in classify(path, name, isDir)) {
|
||||
|
||||
@@ -5,14 +5,12 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
object GameShortcuts {
|
||||
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
|
||||
@@ -21,35 +19,22 @@ object GameShortcuts {
|
||||
|
||||
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
|
||||
val name = game.name.ifBlank { context.getString(R.string.app_name) }
|
||||
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
|
||||
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
|
||||
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
|
||||
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
|
||||
.setIntent(launchIntent(context, game.gameIdString)).build()
|
||||
}
|
||||
|
||||
internal fun iconBitmap(game: Library.SteamGame): Bitmap? {
|
||||
val icon = game.icon?.let(::decode)
|
||||
if (icon != null && minOf(icon.width, icon.height) >= SHARP_ICON_PX) return icon
|
||||
return game.art?.let(::decode)?.let(::square) ?: icon
|
||||
}
|
||||
|
||||
private fun decode(file: File): Bitmap? = runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (minOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= ICON_PX) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)
|
||||
}.getOrNull()
|
||||
|
||||
private fun square(bitmap: Bitmap): Bitmap {
|
||||
val side = minOf(bitmap.width, bitmap.height)
|
||||
return Bitmap.createBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side)
|
||||
}
|
||||
|
||||
fun pin(activity: Activity, game: Library.SteamGame) {
|
||||
val manager = activity.getSystemService(ShortcutManager::class.java)
|
||||
val requested = runCatching { manager.isRequestPinShortcutSupported &&
|
||||
@@ -59,7 +44,4 @@ object GameShortcuts {
|
||||
|
||||
fun result(activity: Activity, game: Library.SteamGame): Intent =
|
||||
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
|
||||
|
||||
private const val ICON_PX = 256
|
||||
private const val SHARP_ICON_PX = 128
|
||||
}
|
||||
@@ -17,15 +17,10 @@ object Library {
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
||||
}
|
||||
const val ADDED = "added"
|
||||
const val EPIC_LAUNCHER = "epic-launcher"
|
||||
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||
const val SOURCE_STEAM = "steam"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
@@ -74,7 +69,7 @@ object Library {
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
|
||||
internal fun steamLibraries(context: Context): List<Pair<File, String>> {
|
||||
private fun steamLibraries(context: Context): List<Pair<File, String>> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
@@ -121,33 +116,21 @@ object Library {
|
||||
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
|
||||
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
|
||||
out[appId] = SteamGameArt.withOverrides(context, SteamGame(
|
||||
out[appId] = SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
|
||||
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
|
||||
return cache.listFiles().orEmpty().mapNotNull { dir ->
|
||||
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
|
||||
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
|
||||
}
|
||||
}
|
||||
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
val list = added ?: AddedGames.scan(context)
|
||||
var steam = steamGames(context, strictRead)
|
||||
val targets = steam.filter { it.library != ADDED && it.source == SOURCE_STEAM }.map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
|
||||
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead)
|
||||
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
|
||||
@@ -161,7 +144,7 @@ object Library {
|
||||
)
|
||||
}
|
||||
|
||||
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
val dirs = listOf(appDir) + appDir.listFiles()
|
||||
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
|
||||
@@ -194,8 +177,7 @@ object Library {
|
||||
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER ||
|
||||
program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER) {
|
||||
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
|
||||
} else com.droiddeck.launcher.session.NoSteamLaunch.nameFor(program, com.droiddeck.launcher.session.SessionState.programArgs)
|
||||
?: specs.firstOrNull { it.program == program }?.name
|
||||
} else specs.firstOrNull { it.program == program }?.name
|
||||
|
||||
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Where Steam pins a game's logo on its hero image and how big it draws it; the same JSON Steam keeps in `<appid>.json`. */
|
||||
object LogoPosition {
|
||||
enum class Pin(val steam: String) {
|
||||
UPPER_LEFT("UpperLeft"), UPPER_CENTER("UpperCenter"), CENTER("CenterCenter"), BOTTOM_LEFT("BottomLeft"), BOTTOM_CENTER("BottomCenter");
|
||||
|
||||
companion object {
|
||||
fun of(steam: String): Pin? = values().firstOrNull { it.steam == steam }
|
||||
}
|
||||
}
|
||||
|
||||
data class Value(val pin: Pin, val widthPct: Int, val heightPct: Int)
|
||||
|
||||
const val MIN_PCT = 10
|
||||
const val MAX_PCT = 100
|
||||
|
||||
/** What Steam draws when a game has no custom position. */
|
||||
val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50)
|
||||
|
||||
private const val VERSION = 1
|
||||
private const val KEY = "logoPosition"
|
||||
|
||||
fun sized(pin: Pin, size: Int): Value = size.coerceIn(MIN_PCT, MAX_PCT).let { Value(pin, it, it) }
|
||||
|
||||
/** `{"nVersion":1,"logoPosition":{...}}`, with whatever else [into] already held kept as it was. */
|
||||
fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into
|
||||
.put("nVersion", VERSION)
|
||||
.put(KEY, JSONObject().put("pinnedPosition", value.pin.steam).put("nWidthPct", value.widthPct).put("nHeightPct", value.heightPct))
|
||||
|
||||
fun parse(text: String): Value? = runCatching { parse(JSONObject(text)) }.getOrNull()
|
||||
|
||||
fun parse(json: JSONObject): Value? {
|
||||
val inner = json.optJSONObject(KEY) ?: return null
|
||||
val pin = Pin.of(inner.optString("pinnedPosition")) ?: return null
|
||||
if (!inner.has("nWidthPct") || !inner.has("nHeightPct")) return null
|
||||
return Value(pin, inner.optInt("nWidthPct").coerceIn(MIN_PCT, MAX_PCT), inner.optInt("nHeightPct").coerceIn(MIN_PCT, MAX_PCT))
|
||||
}
|
||||
|
||||
/** [existing] without the logo position; null when nothing but the version number would be left. */
|
||||
fun without(existing: JSONObject): JSONObject? {
|
||||
existing.remove(KEY)
|
||||
val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList()
|
||||
return if (rest.isEmpty()) null else existing
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object SteamFeatured {
|
||||
class Game(val appId: Int, val name: String, val art: String)
|
||||
|
||||
private const val TAG = "SteamFeatured"
|
||||
private const val PREFS = "steam_featured"
|
||||
private const val KEY_GAMES = "games_v2"
|
||||
private const val KEY_AT = "at"
|
||||
private const val FRESH_MS = 6 * 60 * 60 * 1000L
|
||||
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
|
||||
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
|
||||
|
||||
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
|
||||
|
||||
var games by mutableStateOf<List<Game>>(emptyList())
|
||||
private set
|
||||
private val loading = AtomicBoolean(false)
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
|
||||
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
|
||||
if (!loading.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
try {
|
||||
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
|
||||
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
|
||||
val root = JSONObject(body)
|
||||
val ids = LinkedHashSet<Int>()
|
||||
for (section in SECTIONS) {
|
||||
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
|
||||
for (i in 0 until items.length()) {
|
||||
val o = items.optJSONObject(i) ?: continue
|
||||
val id = o.optInt("id")
|
||||
if (id > 0 && o.optInt("type", 0) == 0) ids += id
|
||||
}
|
||||
}
|
||||
val list = withArt(ids, cc)
|
||||
if (list.isEmpty()) return@Thread
|
||||
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
|
||||
Log.i(TAG, "featured games: ${list.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
|
||||
} finally {
|
||||
loading.set(false)
|
||||
}
|
||||
}, "steam-featured").start()
|
||||
}
|
||||
|
||||
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
val input = JSONObject()
|
||||
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
|
||||
.put("context", JSONObject().put("language", "english").put("country_code", cc))
|
||||
.put("data_request", JSONObject().put("include_assets", true))
|
||||
val body = StoreNet.get(
|
||||
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val o = items.optJSONObject(i) ?: return@mapNotNull null
|
||||
val descriptors = o.optJSONArray("content_descriptorids")
|
||||
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
|
||||
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
|
||||
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
|
||||
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
|
||||
?: return@mapNotNull null
|
||||
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(text: String?): List<Game>? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
|
||||
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.stores.SteamLiveShortcuts
|
||||
import java.io.File
|
||||
|
||||
object SteamGameArt {
|
||||
internal fun dir(context: Context, appId: Int) = File(context.filesDir, "steam-art/$appId")
|
||||
|
||||
private fun sourceFile(context: Context, appId: Int, slot: AddedGameArt.Slot) = File(dir(context, appId), "source-${slot.key}")
|
||||
|
||||
internal fun setSource(context: Context, appId: Int, slot: AddedGameArt.Slot, value: String) {
|
||||
val file = sourceFile(context, appId, slot)
|
||||
if (value.isEmpty()) file.delete() else file.apply { parentFile?.mkdirs() }.writeText(value)
|
||||
}
|
||||
|
||||
fun chosenFile(context: Context, appId: Int, slot: AddedGameArt.Slot): File? = AddedGameArt.chosen(dir(context, appId), slot)
|
||||
|
||||
fun withOverrides(context: Context, game: Library.SteamGame): Library.SteamGame {
|
||||
if (game.library == Library.ADDED || !dir(context, game.appId).isDirectory) return game
|
||||
val cover = chosenFile(context, game.appId, AddedGameArt.Slot.COVER)
|
||||
val hero = chosenFile(context, game.appId, AddedGameArt.Slot.BACKGROUND)
|
||||
val icon = chosenFile(context, game.appId, AddedGameArt.Slot.ICON)
|
||||
if (cover == null && hero == null && icon == null) return game
|
||||
return Library.SteamGame(
|
||||
game.appId, game.name, cover ?: game.art, game.library, game.gameId,
|
||||
hero ?: game.hero, game.lastPlayed, game.gameFiles, game.protonPrefix, icon ?: game.icon, game.source, game.storeId,
|
||||
)
|
||||
}
|
||||
|
||||
fun chosenSource(context: Context, appId: Int, slot: AddedGameArt.Slot): Pair<String, String> {
|
||||
val value = sourceFile(context, appId, slot).takeIf { it.isFile }?.readText()?.trim().orEmpty()
|
||||
return value.substringBefore('|') to value.substringAfter('|', "")
|
||||
}
|
||||
|
||||
fun describe(context: Context, game: Library.SteamGame): List<AddedGameArt.SlotState> {
|
||||
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
|
||||
val own = withOverrides(context, game)
|
||||
return AddedGameArt.Slot.values().map { slot ->
|
||||
chosenFile(context, game.appId, slot)?.let {
|
||||
return@map AddedGameArt.SlotState(slot, it, chosenSource(context, game.appId, slot).first.ifEmpty { AddedGameArt.FILE }, true)
|
||||
}
|
||||
val auto = when (slot) {
|
||||
AddedGameArt.Slot.COVER -> own.art
|
||||
AddedGameArt.Slot.WIDE -> Library.steamCacheImage(cache, game.appId, listOf("library_header.jpg", "header.jpg"))
|
||||
AddedGameArt.Slot.BACKGROUND -> own.hero
|
||||
AddedGameArt.Slot.ICON -> own.icon
|
||||
AddedGameArt.Slot.LOGO -> Library.steamCacheImage(cache, game.appId, listOf("logo.png"))
|
||||
}
|
||||
AddedGameArt.SlotState(slot, auto, if (auto != null) AddedGameArt.STEAM else AddedGameArt.AUTO, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun choices(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot): AddedGameArt.Choices {
|
||||
val out = ArrayList<AddedGameArt.Option>()
|
||||
AddedGameArt.steamOption(game.appId.toString(), slot)?.let { out.add(it) }
|
||||
val (found, status) = AddedGameArt.sgdbChoices(context, File(dir(context, game.appId), "sgdb-id"), game.appId, game.name, slot)
|
||||
out.addAll(found)
|
||||
return AddedGameArt.Choices(out, status)
|
||||
}
|
||||
|
||||
fun choose(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
|
||||
return synchronized(SteamGrid.lock) {
|
||||
val folder = dir(context, game.appId)
|
||||
val fresh = AddedGameArt.storeNew(folder, slot, option) ?: return@synchronized false
|
||||
if (!push(context, game, slot, fresh)) {
|
||||
fresh.delete()
|
||||
return@synchronized false
|
||||
}
|
||||
AddedGameArt.dropOlder(folder, slot, fresh)
|
||||
setSource(context, game.appId, slot, option.group + "|" + option.full)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun reset(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot) {
|
||||
synchronized(SteamGrid.lock) {
|
||||
if (!push(context, game, slot, null)) return@synchronized
|
||||
AddedGameArt.chosen(dir(context, game.appId), slot)?.delete()
|
||||
setSource(context, game.appId, slot, "")
|
||||
}
|
||||
}
|
||||
|
||||
fun position(context: Context, game: Library.SteamGame): LogoPosition.Value? = SteamGrid.position(SteamGrid.target(game, context))
|
||||
|
||||
fun setPosition(context: Context, game: Library.SteamGame, value: LogoPosition.Value?): Boolean {
|
||||
if (!SteamGrid.writePosition(context, SteamGrid.target(game, context), value)) return false
|
||||
AddedGamesPublisher.later(context) { SteamLiveShortcuts.setLogoPosition(context, game.appId.toLong(), value) }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun push(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, file: File?): Boolean {
|
||||
val type = slot.assetType ?: return true
|
||||
if (!SteamGrid.write(context, SteamGrid.target(game, context), slot, file)) return false
|
||||
AddedGamesPublisher.later(context) { SteamLiveShortcuts.setArt(context, game.appId.toLong(), type, file) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
|
||||
object SteamGrid {
|
||||
private const val TAG = "SteamGrid"
|
||||
|
||||
/** Every change to a game's art, here or in a scan, takes this first so two of them never interleave. */
|
||||
internal val lock = Any()
|
||||
private const val MANIFEST = ".droiddeck-art.json"
|
||||
private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.WIDE to "", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo")
|
||||
private val EXTS = listOf("png", "jpg", "jpeg", "webp")
|
||||
|
||||
class Target(val artDir: File, val id: Long, val setSource: (AddedGameArt.Slot, String) -> Unit)
|
||||
|
||||
fun target(game: Library.SteamGame, context: Context) = Target(SteamGameArt.dir(context, game.appId), game.appId.toLong()) { slot, value ->
|
||||
SteamGameArt.setSource(context, game.appId, slot, value)
|
||||
}
|
||||
|
||||
fun target(game: AddedGames.Game, context: Context) = Target(AddedGameArt.cacheDir(context, game), game.appId and 0xFFFFFFFFL) { slot, value ->
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, value)
|
||||
}
|
||||
|
||||
internal fun dir(context: Context): File? {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
return AddedGames.accountConfig(root)?.let { File(it, "grid") }
|
||||
}
|
||||
|
||||
private fun files(grid: File, id: Long, slot: AddedGameArt.Slot): List<File> =
|
||||
EXTS.map { File(grid, "$id${SUFFIX[slot]}.$it") }.filter { it.isFile }
|
||||
|
||||
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
|
||||
|
||||
private fun sigFile(target: Target, slot: AddedGameArt.Slot) = File(target.artDir, "grid-${slot.key}")
|
||||
|
||||
private fun recorded(target: Target, slot: AddedGameArt.Slot): String? = sigFile(target, slot).takeIf { it.isFile }?.readText()
|
||||
|
||||
private fun record(target: Target, slot: AddedGameArt.Slot, value: String) {
|
||||
target.artDir.mkdirs()
|
||||
sigFile(target, slot).writeText(value)
|
||||
}
|
||||
|
||||
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
|
||||
val manifest = JSONObject(File(grid, MANIFEST).readText())
|
||||
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
/** Puts a choice (or, with null, the lack of one) where Steam reads it, whether or not Steam is running. */
|
||||
fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) }
|
||||
|
||||
private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean {
|
||||
val suffix = SUFFIX[slot] ?: return true
|
||||
val grid = dir(context) ?: return true
|
||||
if (file == null) {
|
||||
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
|
||||
record(target, slot, "")
|
||||
return true
|
||||
}
|
||||
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
|
||||
if (ext !in EXTS) return false
|
||||
grid.mkdirs()
|
||||
val dst = File(grid, "${target.id}$suffix.$ext")
|
||||
val tmp = File(grid, dst.name + ".tmp")
|
||||
val placed = runCatching { file.copyTo(tmp, overwrite = true); if (tmp.renameTo(dst)) dst else null }.getOrNull()
|
||||
if (placed == null) {
|
||||
tmp.delete()
|
||||
return false
|
||||
}
|
||||
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
|
||||
record(target, slot, signature(placed))
|
||||
return true
|
||||
}
|
||||
|
||||
private const val POSITION = "logo-position"
|
||||
private const val POSITION_SIG = "grid-json"
|
||||
|
||||
private fun positionFile(grid: File, id: Long) = File(grid, "$id.json")
|
||||
|
||||
private fun positionIn(file: File): LogoPosition.Value? = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }?.let(LogoPosition::parse)
|
||||
|
||||
/** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
|
||||
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
|
||||
|
||||
private fun positionRecorded(target: Target): String? = File(target.artDir, POSITION_SIG).takeIf { it.isFile }?.readText()
|
||||
|
||||
private fun recordPosition(target: Target, value: String) {
|
||||
target.artDir.mkdirs()
|
||||
File(target.artDir, POSITION_SIG).writeText(value)
|
||||
}
|
||||
|
||||
private fun storePosition(target: Target, value: LogoPosition.Value?): Boolean {
|
||||
val file = File(target.artDir, POSITION)
|
||||
if (value == null) return !file.exists() || file.delete()
|
||||
target.artDir.mkdirs()
|
||||
return runCatching { file.writeText(LogoPosition.toJson(value).toString()) }.isSuccess
|
||||
}
|
||||
|
||||
/** Sets (or with null, clears) a game's logo position here and in the client's grid folder, whether or not Steam is running. */
|
||||
fun writePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean = synchronized(lock) {
|
||||
placePosition(context, target, value) && storePosition(target, value)
|
||||
}
|
||||
|
||||
/** Steam keeps the position in `<id>.json`; anything else that file holds is left as it was. */
|
||||
private fun placePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean {
|
||||
val grid = dir(context) ?: return true
|
||||
val file = positionFile(grid, target.id)
|
||||
val existing = if (file.isFile) runCatching { JSONObject(file.readText()) }.getOrNull() else null
|
||||
if (file.isFile && existing == null) return value == null
|
||||
val next = if (value == null) existing?.let(LogoPosition::without) else LogoPosition.toJson(value, existing ?: JSONObject())
|
||||
if (next == null) {
|
||||
if (file.isFile && !file.delete()) return false
|
||||
recordPosition(target, "")
|
||||
return true
|
||||
}
|
||||
grid.mkdirs()
|
||||
val tmp = File(grid, file.name + ".tmp")
|
||||
val placed = runCatching { tmp.writeText(next.toString()); tmp.renameTo(file) }.getOrDefault(false)
|
||||
if (!placed) {
|
||||
tmp.delete()
|
||||
return false
|
||||
}
|
||||
recordPosition(target, signature(file))
|
||||
return true
|
||||
}
|
||||
|
||||
/** A `logoPosition` this version cannot read (a pin it does not know, say) is left alone rather than read as "removed". */
|
||||
private fun hasUnknownPosition(file: File): Boolean {
|
||||
val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false
|
||||
return json.has("logoPosition") && LogoPosition.parse(json) == null
|
||||
}
|
||||
|
||||
private fun reconcilePosition(context: Context, grid: File, target: Target) {
|
||||
val file = positionFile(grid, target.id)
|
||||
if (hasUnknownPosition(file)) return
|
||||
val theirs = positionIn(file)
|
||||
val now = if (theirs != null) signature(file) else ""
|
||||
val before = positionRecorded(target)
|
||||
val own = position(target)
|
||||
when {
|
||||
before == null && own != null && theirs == null -> placePosition(context, target, own)
|
||||
before == null || now != before -> {
|
||||
// Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
|
||||
if (theirs != null) {
|
||||
if (own == theirs || storePosition(target, theirs)) recordPosition(target, now)
|
||||
} else {
|
||||
if (own == null || storePosition(target, null)) recordPosition(target, "")
|
||||
}
|
||||
}
|
||||
own != null && own != theirs -> placePosition(context, target, own)
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes over what changed in Steam since the last look; returns true when a choice was added, replaced or dropped. */
|
||||
fun sync(context: Context, targets: List<Target>): Boolean = synchronized(lock) { reconcile(context, targets) }
|
||||
|
||||
private fun reconcile(context: Context, targets: List<Target>): Boolean {
|
||||
val grid = dir(context) ?: return false
|
||||
val ours = writtenByDroidDeck(grid)
|
||||
var changed = false
|
||||
for (target in targets) for (slot in SUFFIX.keys) {
|
||||
try {
|
||||
val own = AddedGameArt.chosen(target.artDir, slot)
|
||||
val present = files(grid, target.id, slot)
|
||||
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
|
||||
val now = signature(theirs)
|
||||
val before = recorded(target, slot)
|
||||
when {
|
||||
before == null && own != null && theirs == null -> write(context, target, slot, own)
|
||||
before == null || now != before -> {
|
||||
if (theirs != null) {
|
||||
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(target, slot, now)
|
||||
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
|
||||
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
|
||||
record(target, slot, now)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
|
||||
record(target, slot, "")
|
||||
}
|
||||
}
|
||||
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
|
||||
own != null && present.isEmpty() -> write(context, target, slot, own)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${target.id} ${slot.key}: ${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
for (target in targets) {
|
||||
try {
|
||||
reconcilePosition(context, grid, target)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${target.id} logo position: ${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
if (changed) Log.i(TAG, "took over art changed in Steam")
|
||||
return changed
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,6 @@ object SteamGridDb {
|
||||
private const val API = "https://www.steamgriddb.com/api/v2"
|
||||
private const val QUERY = "nsfw=false&humor=false"
|
||||
|
||||
enum class Status { OK, NO_KEY, BAD_KEY, LIMIT, OFFLINE, SERVER, NO_GAME }
|
||||
|
||||
class Failed(val status: Status) : Exception(status.name)
|
||||
|
||||
class Game(val id: Int, val name: String, val verified: Boolean)
|
||||
|
||||
@VisibleForTesting
|
||||
@Volatile
|
||||
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
|
||||
@@ -36,8 +30,6 @@ object SteamGridDb {
|
||||
|
||||
fun key(context: Context): String = pick(userKey(context), builtInKey())
|
||||
|
||||
fun keyChangedAt(context: Context): Long = keyFile(context).lastModified()
|
||||
|
||||
@Synchronized
|
||||
fun userKey(context: Context): String {
|
||||
val f = keyFile(context)
|
||||
@@ -105,56 +97,17 @@ object SteamGridDb {
|
||||
return got
|
||||
}
|
||||
|
||||
fun search(key: String, term: String): List<Game> {
|
||||
val q = term.trim()
|
||||
if (q.isEmpty()) return emptyList()
|
||||
val enc = URLEncoder.encode(q, "UTF-8").replace("+", "%20")
|
||||
val data = json(key, "$API/search/autocomplete/$enc")?.optJSONArray("data") ?: return emptyList()
|
||||
return games(data)
|
||||
}
|
||||
|
||||
internal fun games(data: JSONArray): List<Game> =
|
||||
(0 until data.length()).mapNotNull { data.optJSONObject(it) }
|
||||
.filter { it.optInt("id") > 0 && it.optString("name").isNotEmpty() }
|
||||
.map { Game(it.optInt("id"), it.optString("name"), it.optBoolean("verified")) }
|
||||
|
||||
internal fun path(id: Int, slot: AddedGameArt.Slot): String =
|
||||
when (slot) {
|
||||
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> {
|
||||
val path = when (slot) {
|
||||
AddedGameArt.Slot.COVER -> "grids/game/$id?dimensions=600x900&$QUERY"
|
||||
AddedGameArt.Slot.WIDE -> "grids/game/$id?dimensions=460x215,920x430&$QUERY"
|
||||
AddedGameArt.Slot.BACKGROUND -> "heroes/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.LOGO -> "logos/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.ICON -> "icons/game/$id?$QUERY"
|
||||
}
|
||||
|
||||
fun page(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
|
||||
val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: return emptyList()
|
||||
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: return emptyList()
|
||||
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON).take(limit)
|
||||
}
|
||||
|
||||
private const val PAGE_SIZE = 50
|
||||
private const val MAX_PAGES = 4
|
||||
|
||||
fun all(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
|
||||
val raw = JSONArray()
|
||||
for (n in 0 until MAX_PAGES) {
|
||||
val url = "$API/${path(id, slot)}" + if (n > 0) "&page=$n" else ""
|
||||
val data: JSONArray? = try {
|
||||
json(key, url)?.optJSONArray("data")
|
||||
} catch (e: Failed) {
|
||||
if (n == 0) throw e
|
||||
null
|
||||
}
|
||||
val items = data ?: break
|
||||
for (i in 0 until items.length()) raw.put(items.get(i))
|
||||
if (items.length() < PAGE_SIZE) break
|
||||
}
|
||||
return ranked(raw, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
|
||||
}
|
||||
|
||||
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> =
|
||||
page(key, id, slot).take(limit)
|
||||
|
||||
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
|
||||
(0 until items.length()).map { items.getJSONObject(it) }
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
@@ -166,13 +119,6 @@ object SteamGridDb {
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
.maxByOrNull { it.optInt("score", 0) }?.optString("url")
|
||||
|
||||
internal fun failure(code: Int): Status? = when {
|
||||
code == 401 || code == 403 -> Status.BAD_KEY
|
||||
code == 429 -> Status.LIMIT
|
||||
code >= 500 -> Status.SERVER
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun json(key: String, url: String): JSONObject? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
@@ -180,19 +126,8 @@ object SteamGridDb {
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
|
||||
c.setRequestProperty("Authorization", "Bearer $key")
|
||||
return try {
|
||||
val code = c.responseCode
|
||||
if (code == 200) {
|
||||
JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
|
||||
} else {
|
||||
Log.w(TAG, "${URL(url).path}: HTTP $code")
|
||||
failure(code)?.let { throw Failed(it) }
|
||||
null
|
||||
}
|
||||
} catch (e: Failed) {
|
||||
throw e
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}")
|
||||
throw Failed(Status.OFFLINE)
|
||||
if (c.responseCode != 200) { Log.w(TAG, "${URL(url).path}: HTTP ${c.responseCode}"); null }
|
||||
else JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}"); null
|
||||
} finally {
|
||||
|
||||
@@ -1,862 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
data class GlassesState(
|
||||
val present: Boolean = false,
|
||||
val model: String = "VITURE",
|
||||
val controllable: Boolean = false,
|
||||
val permission: Glasses.Permission = Glasses.Permission.NONE,
|
||||
val connected: Boolean = false,
|
||||
val displayId: Int = Display.INVALID_DISPLAY,
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
val hz: Float = 0f,
|
||||
val sbs: Boolean = false,
|
||||
val switching3d: Boolean = false,
|
||||
val brightness: Int = -1,
|
||||
val volume: Int = -1,
|
||||
val brightnessMax: Int = 8,
|
||||
val volumeMax: Int = 8,
|
||||
val sunblock: Boolean = false,
|
||||
val hardwareControls: Boolean = true,
|
||||
val hasSunblock: Boolean = true,
|
||||
val controlError: String? = null,
|
||||
val tracking: Boolean = false,
|
||||
val diagonalFovDeg: Float = 46f,
|
||||
val noSignal: Boolean = false,
|
||||
val sbsSupport: Int = SBS_UNKNOWN,
|
||||
val sbs90: Boolean = false,
|
||||
val experimental: Boolean = false,
|
||||
) {
|
||||
val hasDisplay: Boolean get() = displayId != Display.INVALID_DISPLAY
|
||||
val usable: Boolean get() = hasDisplay && !noSignal
|
||||
val canControl: Boolean get() = connected && controllable
|
||||
val can3d: Boolean get() = canControl && !noSignal && sbsSupport != SBS_FAILS
|
||||
val verticalFovDeg: Float get() {
|
||||
val half = Math.toRadians(diagonalFovDeg / 2.0)
|
||||
val aspect = if (width > 0 && height > 0)
|
||||
width.toDouble() / (if (sbs) 2 else 1) / height else 16.0 / 9.0
|
||||
val v = kotlin.math.atan(kotlin.math.tan(half) / kotlin.math.sqrt(1 + aspect * aspect))
|
||||
return Math.toDegrees(2 * v).toFloat()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SBS_UNKNOWN = 0
|
||||
const val SBS_WORKS = 1
|
||||
const val SBS_FAILS = -1
|
||||
}
|
||||
}
|
||||
|
||||
data class GlassesSettings(
|
||||
val refreshHz: Int = 120,
|
||||
val sbsHz: Int = 60,
|
||||
val sunblock: Boolean? = null,
|
||||
val gameOnGlasses: Boolean = true,
|
||||
val headTracking: Boolean = true,
|
||||
val auto3d: Boolean = false,
|
||||
val picture3d: Int = PICTURE_AUTO,
|
||||
val screenWidthDm: Int = 20,
|
||||
val screenDistanceDm: Int = 30,
|
||||
val vrFovDeg: Int = VR_FOV_DEFAULT,
|
||||
) {
|
||||
companion object {
|
||||
const val VR_FOV_MIN = 24
|
||||
const val VR_FOV_MAX = 90
|
||||
const val VR_FOV_DEFAULT = 90
|
||||
const val PICTURE_AUTO = 0
|
||||
const val PICTURE_SCREEN = 1
|
||||
const val PICTURE_SBS = 2
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
object Glasses {
|
||||
enum class Permission { NONE, ASKING, DENIED, GRANTED }
|
||||
|
||||
fun interface PoseSink {
|
||||
fun onPose(x: Float, y: Float, z: Float, w: Float, timeNs: Long)
|
||||
}
|
||||
|
||||
private const val TAG = "Glasses"
|
||||
private const val ACTION_PERMISSION = "com.droiddeck.launcher.glasses.USB_PERMISSION"
|
||||
private const val PREFS = "glasses"
|
||||
private const val KEY_SBS_FAILED = "sbsFailed"
|
||||
private const val SBS_CONFIRM_MS = 20_000L
|
||||
|
||||
private val _state = MutableStateFlow(GlassesState())
|
||||
val state: StateFlow<GlassesState> = _state.asStateFlow()
|
||||
private val _settings = MutableStateFlow(GlassesSettings())
|
||||
val settings: StateFlow<GlassesSettings> = _settings.asStateFlow()
|
||||
private val _calibration = MutableStateFlow(GlassesCalibrationState())
|
||||
val calibration: StateFlow<GlassesCalibrationState> = _calibration.asStateFlow()
|
||||
private lateinit var calibrationStore: RayNeoCalibrationStore
|
||||
private var calibrationKey: String? = null
|
||||
private var calibrationRevision = 0L
|
||||
|
||||
private lateinit var app: Context
|
||||
private lateinit var handler: Handler
|
||||
private lateinit var usb: UsbManager
|
||||
private lateinit var displays: DisplayManager
|
||||
@Volatile private var link: GlassesLink? = null
|
||||
@Volatile private var linkGeneration = 0L
|
||||
private var linkDevice: UsbDevice? = null
|
||||
private var model: GlassesModel? = null
|
||||
private var asked = false
|
||||
private var lastModeSentAt = 0L
|
||||
private val modeState = GlassesModeState(SBS_CONFIRM_MS)
|
||||
private val confirmMode = Runnable { refresh() }
|
||||
private var glassesMode = 0
|
||||
private var autoModeSent = false
|
||||
|
||||
@Volatile private var sink: PoseSink? = null
|
||||
@Volatile private var probe: ((ByteArray?, FloatArray?) -> Unit)? = null
|
||||
@Volatile private var latest: FloatArray? = null
|
||||
@Volatile private var imuPackets = 0L
|
||||
private val simulating = java.util.concurrent.atomic.AtomicReference<Thread?>(null)
|
||||
private val frames = ArrayDeque<String>()
|
||||
|
||||
fun init(context: Context) {
|
||||
if (::handler.isInitialized) return
|
||||
app = context.applicationContext
|
||||
usb = app.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
displays = app.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
_settings.value = load()
|
||||
calibrationStore = RayNeoCalibrationStore(app.getSharedPreferences(PREFS, Context.MODE_PRIVATE))
|
||||
handler = Handler(HandlerThread("droiddeck-glasses").apply { start() }.looper)
|
||||
handler.post {
|
||||
val filter = IntentFilter(ACTION_PERMISSION).apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) app.registerReceiver(receiver, filter, null, handler, Context.RECEIVER_NOT_EXPORTED)
|
||||
else app.registerReceiver(receiver, filter, null, handler)
|
||||
displays.registerDisplayListener(displayListener, handler)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun rescan() = post { refresh() }
|
||||
|
||||
fun startCalibration() = post {
|
||||
val l = link as? RayNeoLink ?: return@post
|
||||
if (!l.ready || _calibration.value.running || simulating.get() != null) return@post
|
||||
_calibration.value = _calibration.value.copy(phase = CalibrationPhase.SETTLING, progress = 0)
|
||||
l.startCalibration(++calibrationRevision)
|
||||
updateImu()
|
||||
}
|
||||
|
||||
fun cancelCalibration() = post {
|
||||
(link as? RayNeoLink)?.cancelCalibration(++calibrationRevision, calibrationKey?.let(calibrationStore::load))
|
||||
_calibration.value = _calibration.value.copy(phase = CalibrationPhase.IDLE, progress = 0)
|
||||
updateImu()
|
||||
}
|
||||
|
||||
fun resetCalibration() = post {
|
||||
(link as? RayNeoLink)?.resetCalibration(++calibrationRevision)
|
||||
calibrationKey?.let { calibrationStore.save(it, null) }
|
||||
_calibration.value = _calibration.value.copy(saved = false, phase = CalibrationPhase.IDLE, progress = 0)
|
||||
updateImu()
|
||||
}
|
||||
|
||||
fun query() = post {
|
||||
val l = link ?: return@post
|
||||
listOf(VitureProtocol.MSG_GET_DISPLAY, VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_IMU).forEachIndexed { i, msg ->
|
||||
handler.postDelayed({ if (link === l) l.send(msg) }, VitureLink.COMMAND_GAP_MS * i)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMode(mode: Int) = post {
|
||||
if (!_state.value.canControl) return@post
|
||||
if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) lastModeSentAt = SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
fun requestControl() = post {
|
||||
asked = false
|
||||
refresh(ask = true)
|
||||
}
|
||||
|
||||
fun setRefreshHz(hz: Int) = post {
|
||||
update { it.copy(refreshHz = hz.coerceIn(60, 120)) }
|
||||
if (!_state.value.sbs) applyMode(force = true)
|
||||
}
|
||||
|
||||
fun setSbsHz(hz: Int) = post {
|
||||
update { it.copy(sbsHz = if (hz >= 90) 90 else 60) }
|
||||
if (_state.value.sbs && _state.value.can3d) sendMode(true)
|
||||
}
|
||||
|
||||
fun set3d(on: Boolean) = post {
|
||||
val s = _state.value
|
||||
if (!s.canControl || (on && s.noSignal)) return@post
|
||||
modeState.manual()
|
||||
if (on) {
|
||||
markSbsFailed(linkDevice?.productId, false)
|
||||
rememberSbs(GlassesState.SBS_UNKNOWN)
|
||||
}
|
||||
sendMode(on)
|
||||
}
|
||||
|
||||
fun set3dForVr(on: Boolean) = post {
|
||||
val s = _state.value
|
||||
if (on) {
|
||||
if (!s.can3d || s.sbs || modeState.pendingStereo) return@post
|
||||
if (sendMode(true)) modeState.startVr()
|
||||
} else if (modeState.stopVr() && s.canControl) {
|
||||
sendMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBrightness(level: Int) = post {
|
||||
val s = _state.value
|
||||
if (!s.canControl || s.brightnessMax == 0) return@post
|
||||
val v = level.coerceIn(0, s.brightnessMax)
|
||||
if (link?.send(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(v.toByte(), 0)) == true && link !is XrealLink)
|
||||
publish { it.copy(brightness = v) }
|
||||
}
|
||||
|
||||
fun setVolume(level: Int) = post {
|
||||
val s = _state.value
|
||||
if (!s.canControl || s.volumeMax == 0) return@post
|
||||
val v = level.coerceIn(0, s.volumeMax)
|
||||
if (link?.send(VitureProtocol.MSG_VOLUME, byteArrayOf(v.toByte(), 0)) == true) publish { it.copy(volume = v) }
|
||||
}
|
||||
|
||||
fun setSunblock(on: Boolean) = post {
|
||||
update { it.copy(sunblock = on) }
|
||||
sendFilm(on)
|
||||
}
|
||||
|
||||
fun setSbsSupport(support: Int) = post {
|
||||
val s = support.coerceIn(GlassesState.SBS_FAILS, GlassesState.SBS_WORKS)
|
||||
markSbsFailed(linkDevice?.productId, s == GlassesState.SBS_FAILS)
|
||||
rememberSbs(s)
|
||||
}
|
||||
fun setGameOnGlasses(on: Boolean) = post { update { it.copy(gameOnGlasses = on) } }
|
||||
fun setHeadTracking(on: Boolean) = post { update { it.copy(headTracking = on) } }
|
||||
fun setAuto3d(on: Boolean) = post { update { it.copy(auto3d = on) } }
|
||||
fun setPicture3d(picture: Int) = post { update { it.copy(picture3d = picture.coerceIn(0, 2)) } }
|
||||
fun setScreenWidthDm(dm: Int) = post { update { it.copy(screenWidthDm = dm.coerceIn(5, 200)) } }
|
||||
fun setScreenDistanceDm(dm: Int) = post { update { it.copy(screenDistanceDm = dm.coerceIn(5, 200)) } }
|
||||
fun setVrFovDeg(deg: Int) = post { update { it.copy(vrFovDeg = deg.coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX)) } }
|
||||
|
||||
fun outputHz(): Int = GlassesRendering.outputHz(_settings.value, _state.value)
|
||||
|
||||
fun glassesDisplay(): Display? = _state.value.displayId.takeIf { it != Display.INVALID_DISPLAY }?.let { displays.getDisplay(it) }
|
||||
|
||||
fun setPoseSink(s: PoseSink) {
|
||||
synchronized(this) { sink = s }
|
||||
post { updateImu() }
|
||||
}
|
||||
|
||||
fun clearPoseSink(s: PoseSink) {
|
||||
synchronized(this) { if (sink === s) sink = null }
|
||||
post { updateImu() }
|
||||
}
|
||||
|
||||
fun simulate(seconds: Int, holdYaw: Double? = null, holdPitch: Double? = null): Boolean {
|
||||
val hold = holdYaw != null || holdPitch != null
|
||||
val t = Thread({
|
||||
val start = SystemClock.elapsedRealtimeNanos()
|
||||
val end = start + seconds * 1_000_000_000L
|
||||
while (SystemClock.elapsedRealtimeNanos() < end && simulating.get() === Thread.currentThread()) {
|
||||
val now = SystemClock.elapsedRealtimeNanos()
|
||||
val t = (now - start) / 1e9
|
||||
val yaw = if (hold) holdYaw ?: 0.0 else 40.0 * sin(t * 2 * Math.PI / 6.0)
|
||||
val pitch = if (hold) holdPitch ?: 0.0 else 15.0 * sin(t * 2 * Math.PI / 4.0)
|
||||
val roll = if (hold) 0.0 else 5.0 * sin(t * 2 * Math.PI / 5.0)
|
||||
val q = VitureProtocol.eulerZxy(roll.toFloat(), pitch.toFloat(), yaw.toFloat())
|
||||
deliver(q, now)
|
||||
SystemClock.sleep(4)
|
||||
}
|
||||
simulating.compareAndSet(Thread.currentThread(), null)
|
||||
}, "glasses-simulate")
|
||||
if (!simulating.compareAndSet(null, t)) return false
|
||||
t.start()
|
||||
return true
|
||||
}
|
||||
|
||||
fun stopSimulation() {
|
||||
simulating.set(null)
|
||||
}
|
||||
|
||||
fun status(): JSONObject {
|
||||
val s = _state.value
|
||||
val g = _settings.value
|
||||
val q = latest
|
||||
return JSONObject()
|
||||
.put("present", s.present).put("model", s.model).put("controllable", s.controllable)
|
||||
.put("permission", s.permission.name).put("connected", s.connected)
|
||||
.put("controlError", s.controlError ?: JSONObject.NULL)
|
||||
.put("experimental", s.experimental)
|
||||
.put("display", if (s.hasDisplay) JSONObject().put("id", s.displayId).put("width", s.width).put("height", s.height).put("hz", s.hz.toDouble()) else JSONObject.NULL)
|
||||
.put("sbs", s.sbs).put("deviceMode", glassesMode).put("switching3d", s.switching3d)
|
||||
.put("brightness", s.brightness).put("volume", s.volume)
|
||||
.put("brightnessMax", s.brightnessMax).put("volumeMax", s.volumeMax).put("sunblock", s.sunblock)
|
||||
.put("tracking", s.tracking).put("simulating", simulating.get() != null).put("imuPackets", imuPackets)
|
||||
.put("calibration", JSONObject().put("supported", _calibration.value.supported)
|
||||
.put("saved", _calibration.value.saved).put("phase", _calibration.value.phase.name)
|
||||
.put("progress", _calibration.value.progress))
|
||||
.put("badFrames", link?.badFrames ?: 0L)
|
||||
.put("pose", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL)
|
||||
.put("fovDeg", s.diagonalFovDeg.toDouble()).put("noSignal", s.noSignal).put("sbsSupport", s.sbsSupport)
|
||||
.put("settings", JSONObject().put("refreshHz", g.refreshHz).put("sbsHz", g.sbsHz).put("sunblock", g.sunblock ?: JSONObject.NULL)
|
||||
.put("gameOnGlasses", g.gameOnGlasses).put("headTracking", g.headTracking).put("auto3d", g.auto3d)
|
||||
.put("picture3d", g.picture3d).put("screenWidthDm", g.screenWidthDm).put("screenDistanceDm", g.screenDistanceDm)
|
||||
.put("vrFovDeg", g.vrFovDeg))
|
||||
}
|
||||
|
||||
fun recentFrames(): JSONArray = synchronized(frames) { JSONArray(frames.toList()) }
|
||||
|
||||
fun sendRaw(msgId: Int, payload: ByteArray): Boolean {
|
||||
val l = link ?: return false
|
||||
return l.send(msgId, payload)
|
||||
}
|
||||
|
||||
private fun post(block: () -> Unit) {
|
||||
if (!::handler.isInitialized) return
|
||||
handler.post(block)
|
||||
}
|
||||
|
||||
private fun publish(transform: (GlassesState) -> GlassesState) {
|
||||
_state.value = transform(_state.value)
|
||||
}
|
||||
|
||||
private fun update(transform: (GlassesSettings) -> GlassesSettings) {
|
||||
val next = transform(_settings.value)
|
||||
_settings.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_PERMISSION -> {
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
Log.i(TAG, "USB permission ${if (granted) "granted" else "denied"}")
|
||||
if (!granted) publish { it.copy(permission = Permission.DENIED) }
|
||||
refresh()
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
if (intent.device()?.let { isGlassesVendor(it.vendorId) } == true) asked = false
|
||||
refresh(ask = true)
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> if (intent.device()?.let { isGlassesVendor(it.vendorId) } == true) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = refresh()
|
||||
override fun onDisplayRemoved(displayId: Int) = refresh()
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
if (displayId == _state.value.displayId || displayId != Display.DEFAULT_DISPLAY) refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(ask: Boolean = false) {
|
||||
val devices = usb.deviceList.values.filter {
|
||||
it.vendorId == VitureProtocol.VENDOR_ID || deviceModel(it) != null
|
||||
}
|
||||
val known = devices.mapNotNull { device ->
|
||||
deviceModel(device)?.let { device to it }
|
||||
}
|
||||
val selected = known.firstOrNull { canOpenModel(it.second) } ?: known.firstOrNull()
|
||||
val device = selected?.first
|
||||
val m = selected?.second
|
||||
val controllable = m?.let(::canOpenModel) == true
|
||||
// XREAL uses Android's Ethernet sockets, without opening or claiming USB interfaces.
|
||||
val permitted = controllable && device != null && (m === XrealModel || usb.hasPermission(device))
|
||||
val identity = device?.let {
|
||||
GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null)
|
||||
}
|
||||
if (modeState.updateDevice(identity, SystemClock.uptimeMillis())) {
|
||||
rememberSbs(if (identity != null && sbsFailed(identity.productId)) GlassesState.SBS_FAILS else GlassesState.SBS_UNKNOWN)
|
||||
}
|
||||
if (device == null || (linkDevice != null && linkDevice?.deviceName != device.deviceName)) closeLink()
|
||||
model = m
|
||||
val display = findDisplay(devices.isNotEmpty())
|
||||
val mode = display?.mode
|
||||
val width = mode?.physicalWidth ?: 0
|
||||
val height = mode?.physicalHeight ?: 0
|
||||
val sbsByDisplay = width >= 3000
|
||||
val noSignal = display != null && width in 1 until 1280
|
||||
when (modeState.observe(sbsByDisplay, SystemClock.uptimeMillis())) {
|
||||
GlassesModeState.Confirmation.FAILED -> {
|
||||
Log.w(TAG, "3D signal was not confirmed at ${width}x$height; restoring 2D")
|
||||
if (noSignal) markSbsFailed(device?.productId, true)
|
||||
rememberSbs(GlassesState.SBS_FAILS)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
if (sbsByDisplay) {
|
||||
markSbsFailed(device?.productId, false)
|
||||
rememberSbs(GlassesState.SBS_WORKS)
|
||||
}
|
||||
if (controllable && device != null && !permitted && (ask || !asked) && _state.value.permission != Permission.ASKING) askPermission(device)
|
||||
if (device != null && permitted && link == null) openLink(device)
|
||||
publish {
|
||||
it.copy(
|
||||
present = device != null || display != null,
|
||||
model = m?.name ?: display?.let(::displayName) ?: "VITURE",
|
||||
controllable = controllable,
|
||||
permission = when {
|
||||
!controllable -> Permission.NONE
|
||||
permitted -> Permission.GRANTED
|
||||
it.permission == Permission.ASKING || it.permission == Permission.DENIED -> it.permission
|
||||
else -> Permission.NONE
|
||||
},
|
||||
connected = link?.ready == true,
|
||||
controlError = when {
|
||||
m is VitureProtocol.Model && m.needsSdk && !VitureSdk.available -> app.getString(R.string.glasses_driver_missing)
|
||||
device == null || link?.ready == true -> null
|
||||
else -> it.controlError
|
||||
},
|
||||
displayId = display?.displayId ?: Display.INVALID_DISPLAY,
|
||||
width = width, height = height, hz = mode?.refreshRate ?: 0f,
|
||||
sbs = sbsByDisplay,
|
||||
switching3d = modeState.pendingStereo,
|
||||
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
|
||||
sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock,
|
||||
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel,
|
||||
hasSunblock = m?.hasSunblock == true,
|
||||
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
|
||||
tracking = it.tracking && link != null,
|
||||
noSignal = noSignal,
|
||||
sbs90 = m?.sbs90 == true,
|
||||
experimental = m === XrealModel,
|
||||
)
|
||||
}
|
||||
if (link != null) applyMode(force = false)
|
||||
scheduleModeCheck()
|
||||
}
|
||||
|
||||
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID
|
||||
|
||||
private fun canOpenModel(m: GlassesModel): Boolean = m.controllable &&
|
||||
(m !is VitureProtocol.Model || !m.needsSdk || VitureSdk.available)
|
||||
|
||||
internal fun deviceModel(device: UsbDevice): GlassesModel? {
|
||||
val name = runCatching { device.productName }.getOrNull()
|
||||
return if (device.vendorId == VitureProtocol.VENDOR_ID) {
|
||||
VitureSdk.marketName(device.productId)?.let(VitureProtocol::sdkModel)
|
||||
?: VitureProtocol.model(device.productId, name)
|
||||
}
|
||||
else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel
|
||||
else RayNeoModel.model(device.vendorId, device.productId, name)
|
||||
}
|
||||
|
||||
internal fun isGlassesDisplayName(name: String) =
|
||||
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name)
|
||||
|
||||
private fun findDisplay(usbPresent: Boolean): Display? {
|
||||
return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent)
|
||||
}
|
||||
|
||||
internal fun selectGlassesDisplay(displays: List<Display>, usbPresent: Boolean): Display? {
|
||||
val candidates = displays
|
||||
.filter { it.displayId != Display.DEFAULT_DISPLAY && it.flags and Display.FLAG_PRESENTATION != 0 }
|
||||
// Android can expose the glasses name on either the logical display or
|
||||
// its product metadata. A generic label on one must not hide the other.
|
||||
return candidates.firstOrNull { display ->
|
||||
isGlassesDisplayName(display.name) ||
|
||||
(Build.VERSION.SDK_INT >= 31 && display.deviceProductInfo?.let { product ->
|
||||
product.name?.let(::isGlassesDisplayName) == true ||
|
||||
RayNeoModel.isDisplayProduct(product.name, product.manufacturerPnpId, product.productId)
|
||||
} == true)
|
||||
}
|
||||
?: candidates.singleOrNull()?.takeIf { usbPresent && unnamedPanel(it) }
|
||||
}
|
||||
|
||||
private fun unnamedPanel(d: Display): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 31) return true
|
||||
return d.deviceProductInfo?.name.isNullOrBlank()
|
||||
}
|
||||
|
||||
private fun displayName(d: Display): String? {
|
||||
if (Build.VERSION.SDK_INT >= 31) d.deviceProductInfo?.name?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return d.name
|
||||
}
|
||||
|
||||
private fun askPermission(device: UsbDevice) {
|
||||
asked = true
|
||||
publish { it.copy(permission = Permission.ASKING) }
|
||||
val flags = if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0
|
||||
val pi = PendingIntent.getBroadcast(app, 0, Intent(ACTION_PERMISSION).setPackage(app.packageName), flags)
|
||||
runCatching { usb.requestPermission(device, pi) }.onFailure { Log.w(TAG, "requestPermission", it) }
|
||||
}
|
||||
|
||||
private fun openLink(device: UsbDevice) {
|
||||
val generation = ++linkGeneration
|
||||
calibrationKey = calibrationStore.key(device.vendorId, device.productId,
|
||||
runCatching { device.serialNumber }.getOrNull(), model?.name.orEmpty())
|
||||
val l = if (device.vendorId == VitureProtocol.VENDOR_ID) {
|
||||
if ((model as? VitureProtocol.Model)?.needsSdk == true) {
|
||||
VitureSdkLink.open(usb, device, File(app.cacheDir, "viture"), sdkListener(generation),
|
||||
(model as? VitureProtocol.Model)?.family)
|
||||
} else VitureLink.open(usb, device, linkListener(generation))
|
||||
} else if (model === XrealModel) {
|
||||
XrealLink.open(app, xrealListener(generation))
|
||||
} else {
|
||||
val baseKey = calibrationKey!!
|
||||
val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId)
|
||||
RayNeoLink.open(usb, device, rayNeoListener(generation)) { board ->
|
||||
calibrationStore.load(RayNeoCalibrationStore.profileKey(baseKey, gtUsb, board))
|
||||
}
|
||||
}
|
||||
val opened = l ?: run {
|
||||
linkGeneration++
|
||||
Log.w(TAG, "could not open ${device.deviceName}")
|
||||
publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) }
|
||||
return
|
||||
}
|
||||
link = opened
|
||||
_calibration.value = GlassesCalibrationState(supported = opened is RayNeoLink,
|
||||
saved = opened is RayNeoLink && calibrationKey?.let(calibrationStore::load) != null)
|
||||
linkDevice = device
|
||||
glassesMode = 0
|
||||
autoModeSent = false
|
||||
Log.i(TAG, "opened ${model?.name} pid=0x%04X imu=%s".format(device.productId, opened.hasImu))
|
||||
publish { it.copy(connected = opened.ready, controlError = null) }
|
||||
listOf(VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_DISPLAY).forEachIndexed { i, msg ->
|
||||
handler.postDelayed({ if (link === opened) opened.send(msg) }, VitureLink.COMMAND_GAP_MS * (i + 1))
|
||||
}
|
||||
handler.postDelayed({
|
||||
if (link !== opened) return@postDelayed
|
||||
_settings.value.sunblock?.let { sendFilm(it) }
|
||||
updateImu()
|
||||
applyMode(force = false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
private fun xrealListener(generation: Long) = object : XrealLink.Listener {
|
||||
override fun onReady() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
publish { it.copy(connected = true, controlError = null) }
|
||||
autoModeSent = false
|
||||
updateImu()
|
||||
applyMode(force = false)
|
||||
}
|
||||
override fun onValue(message: Int, value: Int) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
when (message) {
|
||||
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
|
||||
VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) }
|
||||
VitureProtocol.MSG_FILM -> publish { it.copy(sunblock = value != 0) }
|
||||
}
|
||||
}
|
||||
override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) {
|
||||
if (generation != linkGeneration) return
|
||||
imuPackets++
|
||||
probe?.invoke(raw, pose)
|
||||
if (simulating.get() == null) deliver(pose, timeNs)
|
||||
}
|
||||
override fun onUnavailable(networkMissing: Boolean) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
publish { it.copy(connected = false, tracking = false,
|
||||
controlError = app.getString(if (networkMissing) R.string.glasses_xreal_network_missing else R.string.glasses_xreal_connection_failed)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener {
|
||||
override fun onValue(msgId: Int, value: Int) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
when (msgId) {
|
||||
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
|
||||
VitureProtocol.MSG_GET_BRIGHTNESS -> publish { it.copy(brightness = value) }
|
||||
VitureProtocol.MSG_GET_VOLUME -> publish { it.copy(volume = value) }
|
||||
VitureProtocol.MSG_FILM -> publish { it.copy(sunblock = value != 0) }
|
||||
}
|
||||
}
|
||||
override fun onPose(q: FloatArray, timeNs: Long) {
|
||||
if (generation != linkGeneration) return
|
||||
imuPackets++
|
||||
probe?.invoke(null, q)
|
||||
if (simulating.get() == null) deliver(q, timeNs)
|
||||
}
|
||||
override fun onLost() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
closeLink()
|
||||
publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) }
|
||||
// Let a reconnect or explicit Allow retry restart the SDK, not an endless failure loop.
|
||||
}
|
||||
}
|
||||
|
||||
private fun rayNeoListener(generation: Long) = object : RayNeoLink.Listener {
|
||||
override fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
val device = linkDevice ?: return@post
|
||||
val base = calibrationStore.key(device.vendorId, device.productId,
|
||||
runCatching { device.serialNumber }.getOrNull(), model?.name.orEmpty())
|
||||
calibrationKey = RayNeoCalibrationStore.profileKey(base,
|
||||
RayNeoProtocol.isGt(device.vendorId, device.productId), boardId)
|
||||
_calibration.value = _calibration.value.copy(saved = calibrationSaved)
|
||||
}
|
||||
override fun onCalibration(revision: Long, phase: CalibrationPhase, progress: Int) = post {
|
||||
if (generation != linkGeneration || revision != calibrationRevision) return@post
|
||||
if (phase != CalibrationPhase.IDLE && !_calibration.value.running) return@post
|
||||
_calibration.value = _calibration.value.copy(phase = phase, progress = progress)
|
||||
if (!_calibration.value.running) updateImu()
|
||||
}
|
||||
override fun onCalibrationSaved(revision: Long, value: RayNeoCalibration?) = post {
|
||||
if (generation != linkGeneration || revision != calibrationRevision) return@post
|
||||
if (value != null && !_calibration.value.running) return@post
|
||||
calibrationKey?.let { calibrationStore.save(it, value) }
|
||||
_calibration.value = _calibration.value.copy(saved = value != null)
|
||||
}
|
||||
override fun onReady() = post {
|
||||
if (generation == linkGeneration) {
|
||||
publish { it.copy(connected = true) }
|
||||
updateImu()
|
||||
applyMode(force = false)
|
||||
}
|
||||
}
|
||||
override fun onPose(raw: ByteArray, xyzw: FloatArray, timeNs: Long) {
|
||||
if (generation != linkGeneration) return
|
||||
imuPackets++
|
||||
probe?.invoke(raw, xyzw)
|
||||
if (simulating.get() == null) deliver(xyzw, timeNs)
|
||||
}
|
||||
override fun onLost() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
closeLink()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeLink() {
|
||||
val l = link ?: return
|
||||
linkGeneration++
|
||||
link = null
|
||||
linkDevice = null
|
||||
calibrationKey = null
|
||||
_calibration.value = GlassesCalibrationState(phase = if (_calibration.value.running) CalibrationPhase.FAILED else CalibrationPhase.IDLE)
|
||||
glassesMode = 0
|
||||
l.close()
|
||||
publish { it.copy(connected = false, tracking = false) }
|
||||
Log.i(TAG, "link closed")
|
||||
}
|
||||
|
||||
private fun rememberSbs(support: Int) {
|
||||
if (_state.value.sbsSupport == support) return
|
||||
publish { it.copy(sbsSupport = support) }
|
||||
}
|
||||
|
||||
private fun sbsFailed(productId: Int): Boolean =
|
||||
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getStringSet(KEY_SBS_FAILED, null)?.contains(productId.toString(16)) == true
|
||||
|
||||
private fun markSbsFailed(productId: Int?, failed: Boolean) {
|
||||
if (productId == null || sbsFailed(productId) == failed) return
|
||||
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val ids = prefs.getStringSet(KEY_SBS_FAILED, null).orEmpty()
|
||||
val id = productId.toString(16)
|
||||
prefs.edit().putStringSet(KEY_SBS_FAILED, if (failed) ids + id else ids - id).apply()
|
||||
}
|
||||
|
||||
private fun applyMode(force: Boolean) {
|
||||
val s = _state.value
|
||||
val m = model ?: return
|
||||
if (!s.canControl) return
|
||||
if (modeState.restorePending) {
|
||||
sendMode(false)
|
||||
return
|
||||
}
|
||||
if (!s.usable || s.sbs || modeState.pendingStereo) return
|
||||
val hz = _settings.value.refreshHz
|
||||
if (glassesMode == m.mode(hz, false)) return
|
||||
if (!force) {
|
||||
if (autoModeSent || SystemClock.uptimeMillis() - lastModeSentAt < 8_000) return
|
||||
if (glassesMode == 0 && abs(s.hz - hz) < 1.5f) return
|
||||
autoModeSent = true
|
||||
}
|
||||
sendMode(false)
|
||||
}
|
||||
|
||||
private fun sendMode(sbs: Boolean): Boolean {
|
||||
val m = model ?: return false
|
||||
val g = _settings.value
|
||||
val mode = m.mode(if (sbs) g.sbsHz else g.refreshHz, sbs)
|
||||
if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) {
|
||||
if (link is RayNeoLink) glassesMode = mode
|
||||
lastModeSentAt = SystemClock.uptimeMillis()
|
||||
modeState.sent(sbs, lastModeSentAt)
|
||||
publish { it.copy(switching3d = modeState.pendingStereo) }
|
||||
scheduleModeCheck()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun scheduleModeCheck() {
|
||||
handler.removeCallbacks(confirmMode)
|
||||
modeState.nextCheckAt?.let { at ->
|
||||
handler.postDelayed(confirmMode, (at - SystemClock.uptimeMillis()).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendFilm(on: Boolean) {
|
||||
if (!_state.value.canControl || !_state.value.hardwareControls || !_state.value.hasSunblock) return
|
||||
if (link?.send(VitureProtocol.MSG_FILM, byteArrayOf(if (on) 1 else 0)) == true && link !is XrealLink)
|
||||
publish { it.copy(sunblock = on) }
|
||||
}
|
||||
|
||||
private fun updateImu() {
|
||||
val l = link ?: return
|
||||
if (!l.ready) return
|
||||
val want = (sink != null || probe != null || _calibration.value.running) && l.hasImu
|
||||
if (want == _state.value.tracking) return
|
||||
val accepted = l.send(VitureProtocol.MSG_IMU, byteArrayOf(if (want) 1 else 0))
|
||||
if (want && !accepted) return
|
||||
if (want) l.send(VitureProtocol.MSG_IMU_RATE, byteArrayOf(VitureProtocol.IMU_RATE_240.toByte()))
|
||||
publish { it.copy(tracking = want) }
|
||||
}
|
||||
|
||||
private fun linkListener(generation: Long) = object : VitureLink.Listener {
|
||||
override fun onFrame(frame: VitureProtocol.Frame) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
val line = "%s 0x%04X %s".format(
|
||||
when (frame.magic) { VitureProtocol.MAGIC_REPORT -> "FD"; VitureProtocol.MAGIC_COMMAND -> "FE"; else -> "FC" },
|
||||
frame.msgId, frame.payload.joinToString("") { "%02X".format(it) },
|
||||
)
|
||||
synchronized(frames) {
|
||||
frames.addLast(line)
|
||||
while (frames.size > 64) frames.removeFirst()
|
||||
}
|
||||
Log.i(TAG, "frame $line")
|
||||
onReport(frame)
|
||||
}
|
||||
|
||||
override fun onImu(payload: ByteArray, timeNs: Long) {
|
||||
if (generation != linkGeneration) return
|
||||
imuPackets++
|
||||
probe?.invoke(payload, null)
|
||||
if (simulating.get() != null) return
|
||||
VitureProtocol.imuQuaternion(payload)?.let { deliver(it, timeNs) }
|
||||
}
|
||||
|
||||
override fun onLost() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
closeLink()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deliver(q: FloatArray, timeNs: Long) {
|
||||
latest = q
|
||||
sink?.onPose(q[0], q[1], q[2], q[3], timeNs)
|
||||
}
|
||||
|
||||
private fun onReport(frame: VitureProtocol.Frame) {
|
||||
val p = frame.payload
|
||||
val event = p.firstOrNull()?.toInt()?.and(0xFF) ?: return
|
||||
val answer = if (p.size >= 2 && event == 0) p[1].toInt() and 0xFF else null
|
||||
when (frame.msgId) {
|
||||
VitureProtocol.EVENT_DISPLAY -> {
|
||||
Log.i(TAG, "glasses switched to mode 0x%02X".format(event))
|
||||
glassesMode = event
|
||||
}
|
||||
VitureProtocol.EVENT_DISPLAY_STEP -> model?.modeForStep(event)?.let { mode ->
|
||||
Log.i(TAG, "glasses report mode 0x%02X".format(mode))
|
||||
glassesMode = mode
|
||||
}
|
||||
VitureProtocol.EVENT_BRIGHTNESS -> publish { it.copy(brightness = event) }
|
||||
VitureProtocol.EVENT_VOLUME -> publish { it.copy(volume = event) }
|
||||
VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_BRIGHTNESS -> answer?.let { v -> publish { it.copy(brightness = v) } }
|
||||
VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_VOLUME -> answer?.let { v -> publish { it.copy(volume = v) } }
|
||||
VitureProtocol.MSG_DISPLAY -> if (event != 0) {
|
||||
Log.w(TAG, "glasses refused display mode 0x%02X".format(p.getOrNull(1)?.toInt()?.and(0xFF) ?: 0))
|
||||
modeState.cancelPending()
|
||||
publish { it.copy(switching3d = false) }
|
||||
scheduleModeCheck()
|
||||
} else answer?.let { glassesMode = it }
|
||||
VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v ->
|
||||
glassesMode = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sampleImu(ms: Long): JSONObject {
|
||||
val raw = java.util.Collections.synchronizedList(mutableListOf<Pair<ByteArray?, FloatArray?>>())
|
||||
val counter = java.util.concurrent.atomic.AtomicInteger()
|
||||
val began = SystemClock.elapsedRealtime()
|
||||
probe = { payload, pose -> val n = counter.incrementAndGet(); if (raw.size < 4 || n % 60 == 0) raw.add(payload?.copyOf() to pose?.copyOf()) }
|
||||
post { updateImu() }
|
||||
SystemClock.sleep(ms)
|
||||
probe = null
|
||||
post { updateImu() }
|
||||
val seconds = (SystemClock.elapsedRealtime() - began) / 1000.0
|
||||
val count = counter.get()
|
||||
val samples = JSONArray()
|
||||
synchronized(raw) {
|
||||
raw.forEach { (b, pose) ->
|
||||
val rayneo = b != null && RayNeoProtocol.classifyPacket(b) is RayNeoPacket.Sensor
|
||||
val q = pose ?: if (b == null || rayneo) null else VitureProtocol.imuQuaternion(b)
|
||||
samples.put(JSONObject()
|
||||
.put("hex", b?.joinToString("") { "%02X".format(it) } ?: JSONObject.NULL)
|
||||
.put("euler", if (b == null || b.size < 12 || rayneo || pose != null) JSONObject.NULL else JSONArray().put(VitureProtocol.beFloat(b, 0).toDouble()).put(VitureProtocol.beFloat(b, 4).toDouble()).put(VitureProtocol.beFloat(b, 8).toDouble()))
|
||||
.put("quat", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL))
|
||||
}
|
||||
}
|
||||
return JSONObject().put("packets", count).put("rateHz", count / seconds).put("samples", samples)
|
||||
}
|
||||
|
||||
private fun Intent.device(): UsbDevice? =
|
||||
if (Build.VERSION.SDK_INT >= 33) getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
|
||||
else @Suppress("DEPRECATION") getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
|
||||
private fun load(): GlassesSettings {
|
||||
val p = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return GlassesSettings(
|
||||
refreshHz = p.getInt("refreshHz", 120),
|
||||
sbsHz = p.getInt("sbsHz", 60),
|
||||
sunblock = if (p.contains("sunblock")) p.getBoolean("sunblock", false) else null,
|
||||
gameOnGlasses = p.getBoolean("gameOnGlasses", true),
|
||||
headTracking = p.getBoolean("headTracking", true),
|
||||
auto3d = p.getBoolean("auto3d", GlassesSettings().auto3d),
|
||||
picture3d = p.getInt("picture3d", GlassesSettings.PICTURE_AUTO),
|
||||
screenWidthDm = p.getInt("screenWidthDm", 20),
|
||||
screenDistanceDm = p.getInt("screenDistanceDm", 30),
|
||||
vrFovDeg = p.getInt("vrFovDeg", GlassesSettings.VR_FOV_DEFAULT).coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX),
|
||||
)
|
||||
}
|
||||
|
||||
private fun save(s: GlassesSettings) {
|
||||
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
|
||||
putInt("refreshHz", s.refreshHz)
|
||||
putInt("sbsHz", s.sbsHz)
|
||||
s.sunblock?.let { putBoolean("sunblock", it) } ?: remove("sunblock")
|
||||
putBoolean("gameOnGlasses", s.gameOnGlasses)
|
||||
putBoolean("headTracking", s.headTracking)
|
||||
putBoolean("auto3d", s.auto3d)
|
||||
putInt("picture3d", s.picture3d)
|
||||
putInt("screenWidthDm", s.screenWidthDm)
|
||||
putInt("screenDistanceDm", s.screenDistanceDm)
|
||||
putInt("vrFovDeg", s.vrFovDeg)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
/** Hardware properties; display refresh is selected from Android's advertised modes. */
|
||||
interface GlassesModel {
|
||||
val name: String
|
||||
val controllable: Boolean
|
||||
val brightnessMax: Int
|
||||
val volumeMax: Int
|
||||
val diagonalFovDeg: Float
|
||||
val sbs90: Boolean
|
||||
val hasSunblock: Boolean get() = true
|
||||
fun mode(hz: Int, sbs: Boolean): Int
|
||||
fun modeForStep(step: Int): Int?
|
||||
}
|
||||
|
||||
/** The command IDs here are the existing glasses controller's internal vocabulary.
|
||||
* Each transport translates them to its own wire protocol; unsupported controls return false.
|
||||
*/
|
||||
internal interface GlassesLink {
|
||||
val hasImu: Boolean
|
||||
val badFrames: Long get() = 0L
|
||||
val ready: Boolean get() = true
|
||||
fun send(msgId: Int, payload: ByteArray = ByteArray(0)): Boolean
|
||||
fun close()
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
internal class GlassesModeState(private val timeoutMs: Long) {
|
||||
enum class Confirmation { NONE, WAITING, CONFIRMED, FAILED }
|
||||
data class Device(val productId: Int, val serial: String?)
|
||||
|
||||
private var deadline: Long? = null
|
||||
private var device: Device? = null
|
||||
private var attached = false
|
||||
private var reconnectDeadline: Long? = null
|
||||
private var vrOwned = false
|
||||
var restorePending = false
|
||||
private set
|
||||
val pendingStereo: Boolean get() = deadline != null
|
||||
val nextCheckAt: Long? get() = listOfNotNull(deadline, reconnectDeadline).minOrNull()
|
||||
|
||||
fun updateDevice(current: Device?, now: Long): Boolean {
|
||||
if (reconnectDeadline?.let { now >= it } == true) abandon()
|
||||
if (current == null) {
|
||||
if (attached) reconnectDeadline = now + timeoutMs
|
||||
attached = false
|
||||
return false
|
||||
}
|
||||
val previous = device
|
||||
val changed = previous == null || previous.productId != current.productId ||
|
||||
(previous.serial != null && current.serial != null && previous.serial != current.serial)
|
||||
if (changed) abandon()
|
||||
device = if (!changed && current.serial == null) previous else current
|
||||
attached = true
|
||||
reconnectDeadline = null
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun abandon() {
|
||||
deadline = null
|
||||
reconnectDeadline = null
|
||||
vrOwned = false
|
||||
restorePending = false
|
||||
}
|
||||
|
||||
fun manual() {
|
||||
vrOwned = false
|
||||
restorePending = false
|
||||
}
|
||||
|
||||
fun startVr() {
|
||||
vrOwned = true
|
||||
restorePending = false
|
||||
}
|
||||
|
||||
fun stopVr(): Boolean {
|
||||
if (vrOwned) {
|
||||
vrOwned = false
|
||||
restorePending = true
|
||||
}
|
||||
return restorePending
|
||||
}
|
||||
|
||||
fun sent(stereo: Boolean, now: Long) {
|
||||
deadline = if (stereo) now + timeoutMs else null
|
||||
if (!stereo) restorePending = false
|
||||
}
|
||||
|
||||
fun cancelPending() {
|
||||
deadline = null
|
||||
}
|
||||
|
||||
fun observe(stereo: Boolean, now: Long): Confirmation {
|
||||
val until = deadline ?: return Confirmation.NONE
|
||||
if (stereo) {
|
||||
deadline = null
|
||||
return Confirmation.CONFIRMED
|
||||
}
|
||||
if (now < until) return Confirmation.WAITING
|
||||
if (!attached) {
|
||||
abandon()
|
||||
return Confirmation.NONE
|
||||
}
|
||||
deadline = null
|
||||
vrOwned = false
|
||||
restorePending = true
|
||||
return Confirmation.FAILED
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import com.droiddeck.launcher.gpu.Cardboard
|
||||
|
||||
/** Share the actual picture mode with settings so controls match the rendered view. */
|
||||
internal object GlassesRendering {
|
||||
enum class Picture { FLAT, VIRTUAL_SCREEN, STEREO }
|
||||
|
||||
fun outputHz(settings: GlassesSettings, state: GlassesState): Int =
|
||||
if (state.sbs) if (state.sbs90) settings.sbsHz else 60 else settings.refreshHz
|
||||
|
||||
fun mirrorRefreshLimit(settings: GlassesSettings, state: GlassesState, outputActive: Boolean): Int =
|
||||
if (state.usable && !outputActive) outputHz(settings, state) else 0
|
||||
|
||||
fun fpsLimit(requested: Int, mirrorRefreshLimit: Int): Int = when {
|
||||
mirrorRefreshLimit <= 0 -> requested
|
||||
requested <= 0 -> mirrorRefreshLimit
|
||||
else -> minOf(requested, mirrorRefreshLimit)
|
||||
}
|
||||
|
||||
fun usesGlasses(state: GlassesState, outputActive: Boolean, steamVrRunning: Boolean): Boolean =
|
||||
outputActive || (steamVrRunning && state.usable)
|
||||
|
||||
fun picture(settings: GlassesSettings, stereoDisplay: Boolean, steamVrRunning: Boolean): Picture =
|
||||
when (settings.picture3d) {
|
||||
GlassesSettings.PICTURE_SCREEN -> Picture.VIRTUAL_SCREEN
|
||||
GlassesSettings.PICTURE_SBS -> Picture.STEREO
|
||||
else -> when {
|
||||
steamVrRunning -> Picture.STEREO
|
||||
stereoDisplay -> Picture.VIRTUAL_SCREEN
|
||||
else -> Picture.FLAT
|
||||
}
|
||||
}
|
||||
|
||||
fun cardboard(
|
||||
base: Cardboard,
|
||||
settings: GlassesSettings,
|
||||
state: GlassesState,
|
||||
outputActive: Boolean,
|
||||
steamVrRunning: Boolean,
|
||||
): Cardboard {
|
||||
// Mirror output still uses the glasses' picture and sensors during VR.
|
||||
if (!usesGlasses(state, outputActive, steamVrRunning)) {
|
||||
// SteamVR uses the renderer's enabled flag as headset proximity.
|
||||
// A flat phone preview still needs an active HMD and one SBS eye;
|
||||
// otherwise SteamVR enters standby and leaves a frozen frame.
|
||||
return if (steamVrRunning && !base.enabled) base.copy(
|
||||
enabled = true,
|
||||
source = Cardboard.SOURCE_SBS,
|
||||
viewer = Cardboard.Viewer.NONE.id,
|
||||
mono = true,
|
||||
) else base
|
||||
}
|
||||
val picture = picture(settings, state.sbs, steamVrRunning)
|
||||
if (picture == Picture.FLAT) return Cardboard.OFF
|
||||
return Cardboard(
|
||||
enabled = true,
|
||||
source = if (picture == Picture.STEREO) Cardboard.SOURCE_SBS else Cardboard.SOURCE_SCREEN,
|
||||
viewer = Cardboard.Viewer.VITURE.id,
|
||||
ipdMm = base.ipdMm,
|
||||
screenWidthDm = settings.screenWidthDm,
|
||||
screenDistanceDm = settings.screenDistanceDm,
|
||||
headTracking = settings.headTracking && state.canControl,
|
||||
mono = !state.sbs,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.app.Presentation
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import kotlin.math.abs
|
||||
|
||||
class GlassesScreen(context: Context, display: Display, refreshHz: Int, private val callback: SurfaceHolder.Callback) : Presentation(context, display) {
|
||||
val surfaceView = SurfaceView(getContext())
|
||||
val holder: SurfaceHolder get() = surfaceView.holder
|
||||
private var wantHz = refreshHz
|
||||
|
||||
val hz: Float get() = display.mode.refreshRate
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val root = FrameLayout(context).apply { setBackgroundColor(Color.BLACK) }
|
||||
root.addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
setContentView(root)
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
applyMode()
|
||||
surfaceView.holder.addCallback(callback)
|
||||
}
|
||||
|
||||
fun prefer(hz: Int) {
|
||||
wantHz = hz
|
||||
// A 2D/SBS transition can replace the active mode without changing Hz.
|
||||
applyMode()
|
||||
}
|
||||
|
||||
private fun applyMode() {
|
||||
val w = window ?: return
|
||||
val current = display.mode
|
||||
val best = display.supportedModes.filter {
|
||||
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
|
||||
}.minByOrNull { abs(it.refreshRate - wantHz) } ?: return
|
||||
if (w.attributes.preferredDisplayModeId != best.modeId) {
|
||||
Log.i("GlassesScreen", "display ${display.displayId}: prefer mode ${best.modeId} " +
|
||||
"${best.physicalWidth}x${best.physicalHeight}@${best.refreshRate} (requested $wantHz Hz)")
|
||||
w.attributes = w.attributes.apply { preferredDisplayModeId = best.modeId }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class CalibrationPhase { IDLE, SETTLING, COLLECTING, MOVING, TURN_OVER, SAVED, FAILED }
|
||||
|
||||
data class GlassesCalibrationState(
|
||||
val supported: Boolean = false,
|
||||
val saved: Boolean = false,
|
||||
val phase: CalibrationPhase = CalibrationPhase.IDLE,
|
||||
val progress: Int = 0,
|
||||
) {
|
||||
val running: Boolean get() = phase in setOf(CalibrationPhase.SETTLING, CalibrationPhase.COLLECTING,
|
||||
CalibrationPhase.MOVING, CalibrationPhase.TURN_OVER)
|
||||
}
|
||||
|
||||
internal data class RayNeoCalibration(val gyroBiasDps: Vec3, val level: Quaternion) {
|
||||
fun encode(): String = JSONObject().put("version", 1)
|
||||
.put("bias", JSONArray(listOf(gyroBiasDps.x, gyroBiasDps.y, gyroBiasDps.z)))
|
||||
.put("level", JSONArray(listOf(level.w, level.x, level.y, level.z))).toString()
|
||||
|
||||
companion object {
|
||||
fun decode(json: String?): RayNeoCalibration? = runCatching {
|
||||
val obj = JSONObject(json ?: return null)
|
||||
require(obj.getInt("version") == 1)
|
||||
val b = obj.getJSONArray("bias"); val q = obj.getJSONArray("level")
|
||||
require(b.length() == 3 && q.length() == 4)
|
||||
val bias = Vec3(b.getDouble(0).toFloat(), b.getDouble(1).toFloat(), b.getDouble(2).toFloat())
|
||||
val level = Quaternion(q.getDouble(0).toFloat(), q.getDouble(1).toFloat(), q.getDouble(2).toFloat(), q.getDouble(3).toFloat())
|
||||
require(listOf(bias.x, bias.y, bias.z, level.w, level.x, level.y, level.z).all { it.isFinite() })
|
||||
require(bias.length() <= 6f && level.w in 0.96f..1.001f)
|
||||
val norm = level.w * level.w + level.x * level.x + level.y * level.y + level.z * level.z
|
||||
require(norm in 0.99f..1.01f)
|
||||
RayNeoCalibration(bias, level.normalized())
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) {
|
||||
companion object {
|
||||
// GT and GT Max can share USB identity fields. Keep the non-Max mounting
|
||||
// correction separate from existing Max profiles and their legacy keys.
|
||||
fun profileKey(base: String, gtUsb: Boolean, boardId: Int): String =
|
||||
if (gtUsb && boardId == RayNeoProtocol.BOARD_GT) "$base:gt-standard-v1" else base
|
||||
}
|
||||
// A serial isolates physical units. Devices without one share their model profile.
|
||||
fun key(vid: Int, pid: Int, serial: String?, modelName: String = ""): String =
|
||||
"rayneo-cal-v1:$vid:$pid:${serial?.takeIf { it.isNotBlank() } ?: "model:$modelName"}"
|
||||
fun load(key: String): RayNeoCalibration? = RayNeoCalibration.decode(prefs.getString(key, null))
|
||||
fun save(key: String, value: RayNeoCalibration?) {
|
||||
if (value == null) prefs.edit().remove(key).apply()
|
||||
else prefs.edit().putString(key, value.encode()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** One upside-down stationary pose estimates gyro bias and tilt, never yaw or accelerometer scale. */
|
||||
internal class RayNeoCalibrationCollector(private val axes: RayNeoSensorAxes, private val beganMs: Long) {
|
||||
var phase = CalibrationPhase.SETTLING; private set
|
||||
var progress = 0; private set
|
||||
private var windowStart = -1L
|
||||
private var lastMs = -1L
|
||||
private var count = 0
|
||||
private var meanA = Vec3(0f, 0f, 0f)
|
||||
private var meanG = Vec3(0f, 0f, 0f)
|
||||
private var aVariance = 0f
|
||||
private var gVariance = 0f
|
||||
|
||||
private fun clear(next: CalibrationPhase) {
|
||||
phase = next; progress = 0; windowStart = -1; count = 0
|
||||
meanA = Vec3(0f, 0f, 0f); meanG = meanA; aVariance = 0f; gVariance = 0f
|
||||
}
|
||||
|
||||
fun update(sample: RayNeoSensorSample, nowMs: Long): RayNeoCalibration? {
|
||||
if (phase == CalibrationPhase.FAILED || phase == CalibrationPhase.SAVED) return null
|
||||
if (nowMs - beganMs >= 30_000) { clear(CalibrationPhase.FAILED); return null }
|
||||
val gap = lastMs >= 0 && nowMs - lastMs > 250
|
||||
lastMs = nowMs
|
||||
if (nowMs - beganMs < 3000) return null
|
||||
val a = axes.map(Vec3(sample.accelMps2[0], sample.accelMps2[1], sample.accelMps2[2]))
|
||||
val g = Vec3(sample.gyroDps[0], sample.gyroDps[1], sample.gyroDps[2])
|
||||
if (a.length() !in 8.5f..11f || !g.length().isFinite() || g.length() > 6f || gap) {
|
||||
clear(CalibrationPhase.MOVING); return null
|
||||
}
|
||||
if (a.normalized().y > -0.94f) { clear(CalibrationPhase.TURN_OVER); return null }
|
||||
if (windowStart < 0) windowStart = nowMs
|
||||
phase = CalibrationPhase.COLLECTING
|
||||
count++
|
||||
val da = a - meanA; val dg = g - meanG
|
||||
meanA += da * (1f / count); meanG += dg * (1f / count)
|
||||
fun dot(x: Vec3, y: Vec3) = x.x * y.x + x.y * y.y + x.z * y.z
|
||||
aVariance += dot(da, a - meanA); gVariance += dot(dg, g - meanG)
|
||||
if (count > 25 && (aVariance / count > 0.04f || gVariance / count > 2.25f)) {
|
||||
clear(CalibrationPhase.MOVING); return null
|
||||
}
|
||||
progress = ((nowMs - windowStart) * 100 / 5000).toInt().coerceIn(0, 99)
|
||||
if (nowMs - windowStart < 5000 || count < 200) return null
|
||||
val down = meanA.normalized()
|
||||
val axis = down.cross(Vec3(0f, -1f, 0f))
|
||||
val result = RayNeoCalibration(meanG, Quaternion(1f - down.y, axis.x, axis.y, axis.z).normalized())
|
||||
phase = CalibrationPhase.SAVED; progress = 100
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
|
||||
/** RayNeo command/response and IMU share one endpoint. Keep reads on a single worker. */
|
||||
internal class RayNeoLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
private val endpoints: RayNeoEndpointSelection,
|
||||
private val listener: Listener,
|
||||
private val gt: Boolean,
|
||||
private val loadCalibration: (Int) -> RayNeoCalibration?,
|
||||
) : GlassesLink {
|
||||
interface Listener {
|
||||
fun onReady()
|
||||
fun onPose(raw: ByteArray, xyzw: FloatArray, timeNs: Long)
|
||||
fun onLost()
|
||||
fun onCalibration(revision: Long, phase: CalibrationPhase, progress: Int) {}
|
||||
fun onCalibrationSaved(revision: Long, value: RayNeoCalibration?) {}
|
||||
fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) {}
|
||||
}
|
||||
|
||||
@Volatile private var running = true
|
||||
@Volatile override var ready = false
|
||||
private set
|
||||
@Volatile private var wantImu = false
|
||||
private val writeLock = Any()
|
||||
private val assembler = RayNeoPacketAssembler()
|
||||
private val readBuffer = ByteArray(maxOf(endpoints.inEndpoint.maxPacketSize, 64))
|
||||
private var failures = 0
|
||||
private lateinit var worker: Thread
|
||||
private var calibration: RayNeoCalibration? = null
|
||||
private data class CalibrationCommand(val revision: Long, val action: Int, val saved: RayNeoCalibration? = null)
|
||||
private val calibrationCommand = java.util.concurrent.atomic.AtomicReference<CalibrationCommand?>(null)
|
||||
fun startCalibration(revision: Long) { calibrationCommand.set(CalibrationCommand(revision, 1)) }
|
||||
fun cancelCalibration(revision: Long, saved: RayNeoCalibration?) { calibrationCommand.set(CalibrationCommand(revision, 2, saved)) }
|
||||
fun resetCalibration(revision: Long) { calibrationCommand.set(CalibrationCommand(revision, 3)) }
|
||||
override val hasImu = true
|
||||
|
||||
override fun send(msgId: Int, payload: ByteArray): Boolean {
|
||||
if (!running) return false
|
||||
return when (msgId) {
|
||||
VitureProtocol.MSG_IMU -> {
|
||||
wantImu = payload.firstOrNull() == 1.toByte()
|
||||
true
|
||||
}
|
||||
VitureProtocol.MSG_DISPLAY -> {
|
||||
val mode = payload.firstOrNull()?.toInt()?.and(0xff)
|
||||
ready && (mode == RayNeoProtocol.CMD_SWITCH_TO_3D || mode == RayNeoProtocol.CMD_SWITCH_TO_2D) &&
|
||||
command(mode)
|
||||
}
|
||||
// No VITURE brightness, volume, film, rate or raw command is sent to RayNeo.
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun command(cmd: Int): Boolean = synchronized(writeLock) {
|
||||
if (!running) return false
|
||||
val packet = RayNeoProtocol.buildCommandPacket(cmd, 0, endpoints.outEndpoint.maxPacketSize)
|
||||
connection.bulkTransfer(endpoints.outEndpoint, packet, packet.size, 250) == packet.size
|
||||
}
|
||||
|
||||
private fun read(timeoutMs: Int): ByteArray? {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (running && SystemClock.elapsedRealtime() < deadline) {
|
||||
assembler.nextPacket()?.let { return it }
|
||||
val began = SystemClock.elapsedRealtime()
|
||||
val count = connection.bulkTransfer(endpoints.inEndpoint, readBuffer, readBuffer.size, 50)
|
||||
if (count > 0) {
|
||||
failures = 0
|
||||
assembler.append(readBuffer, count)
|
||||
} else if (count < 0 && SystemClock.elapsedRealtime() - began < 25) {
|
||||
if (++failures >= 50) error("RayNeo USB link lost")
|
||||
SystemClock.sleep(5)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun response(cmd: Int, timeoutMs: Int): RayNeoPacket.Response? {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (running && SystemClock.elapsedRealtime() < deadline) {
|
||||
val packet = read(100)?.let(RayNeoProtocol::classifyPacket)
|
||||
if (packet is RayNeoPacket.Response && packet.cmd == cmd) return packet
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun run() {
|
||||
var streaming = false
|
||||
try {
|
||||
check(command(RayNeoProtocol.CMD_ACQUIRE_DEVICE_INFO)) { "RayNeo device-info write failed" }
|
||||
val info = response(RayNeoProtocol.CMD_ACQUIRE_DEVICE_INFO, 2500)
|
||||
?.let { RayNeoProtocol.parseDeviceInfo(it.raw) } ?: error("RayNeo device-info timed out")
|
||||
val profile = RayNeoSensorProfile.forDevice(gt, info.boardId)
|
||||
val rotation = profile.rotationXDeg
|
||||
val gtMaxAxes = profile.gtMaxAxes
|
||||
calibration = loadCalibration(info.boardId)
|
||||
listener.onDeviceInfo(info.boardId, calibration != null)
|
||||
Log.i("RayNeoLink", "Device board=0x%02x gt=%s".format(info.boardId, gt))
|
||||
var filter = RayNeoOrientationFilter(rotation, gtMaxAxes, calibration)
|
||||
var collector: RayNeoCalibrationCollector? = null
|
||||
var calibrationRevision = 0L
|
||||
var lastCalibrationUpdate = 0L
|
||||
var warmupUntil = 0L
|
||||
var lastSensor = 0L
|
||||
ready = true
|
||||
listener.onReady()
|
||||
while (running) {
|
||||
val request = calibrationCommand.getAndSet(null)
|
||||
if (request != null) calibrationRevision = request.revision
|
||||
when (request?.action) {
|
||||
1 -> collector = RayNeoCalibrationCollector(RayNeoSensorAxes(rotation, gtMaxAxes), SystemClock.elapsedRealtime())
|
||||
2 -> {
|
||||
collector = null
|
||||
if (calibration != request.saved) {
|
||||
calibration = request.saved
|
||||
filter = RayNeoOrientationFilter(rotation, gtMaxAxes, calibration)
|
||||
}
|
||||
listener.onCalibration(calibrationRevision, CalibrationPhase.IDLE, 0)
|
||||
}
|
||||
3 -> {
|
||||
collector = null; calibration = null
|
||||
filter = RayNeoOrientationFilter(rotation, gtMaxAxes)
|
||||
listener.onCalibrationSaved(calibrationRevision, null)
|
||||
listener.onCalibration(calibrationRevision, CalibrationPhase.IDLE, 0)
|
||||
}
|
||||
}
|
||||
if (wantImu != streaming) {
|
||||
val cmd = if (wantImu) RayNeoProtocol.CMD_OPEN_IMU else RayNeoProtocol.CMD_CLOSE_IMU
|
||||
check(command(cmd) && response(cmd, 1500) != null) { "RayNeo IMU command timed out" }
|
||||
streaming = cmd == RayNeoProtocol.CMD_OPEN_IMU
|
||||
filter = RayNeoOrientationFilter(rotation, gtMaxAxes, calibration)
|
||||
lastSensor = SystemClock.elapsedRealtime()
|
||||
warmupUntil = lastSensor + 2000
|
||||
}
|
||||
val raw = read(100)
|
||||
if (streaming && SystemClock.elapsedRealtime() - lastSensor > 4000) error("RayNeo IMU stream stalled")
|
||||
if (raw == null) continue
|
||||
val packet = RayNeoProtocol.classifyPacket(raw)
|
||||
if (streaming && packet is RayNeoPacket.Sensor) {
|
||||
lastSensor = SystemClock.elapsedRealtime()
|
||||
collector?.let { c ->
|
||||
val result = c.update(packet.sample, lastSensor)
|
||||
if (result != null && calibrationCommand.get() == null) {
|
||||
calibration = result
|
||||
filter = RayNeoOrientationFilter(rotation, gtMaxAxes, result)
|
||||
warmupUntil = lastSensor + 2000
|
||||
listener.onCalibrationSaved(calibrationRevision, result)
|
||||
}
|
||||
if (lastSensor - lastCalibrationUpdate >= 200 || result != null || c.phase == CalibrationPhase.FAILED) {
|
||||
listener.onCalibration(calibrationRevision, c.phase, c.progress)
|
||||
lastCalibrationUpdate = lastSensor
|
||||
}
|
||||
if (result != null || c.phase == CalibrationPhase.FAILED) collector = null
|
||||
}
|
||||
val wxyz = filter.update(packet.sample)
|
||||
if (wantImu && collector == null && lastSensor >= warmupUntil) {
|
||||
// Normalize basis and component order at the hardware boundary.
|
||||
listener.onPose(raw, RayNeoOrientationFilter.glassesPose(wxyz),
|
||||
SystemClock.elapsedRealtimeNanos())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (running) Log.w("RayNeoLink", "RayNeo stream failed", e)
|
||||
} finally {
|
||||
ready = false
|
||||
if (running) listener.onLost()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(writeLock) {
|
||||
if (!running) return
|
||||
// Best effort: stop the sensor before releasing the claimed interface.
|
||||
if (ready) runCatching { command(RayNeoProtocol.CMD_CLOSE_IMU) }
|
||||
running = false
|
||||
}
|
||||
if (Thread.currentThread() !== worker) worker.join(500)
|
||||
runCatching { connection.releaseInterface(endpoints.usbInterface) }
|
||||
connection.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun open(usb: UsbManager, device: UsbDevice, listener: Listener, loadCalibration: (Int) -> RayNeoCalibration? = { null }): RayNeoLink? {
|
||||
val endpoints = RayNeoProtocol.selectEndpoints(device) ?: return null
|
||||
val conn = usb.openDevice(device) ?: return null
|
||||
if (!conn.claimInterface(endpoints.usbInterface, true)) {
|
||||
conn.close()
|
||||
return null
|
||||
}
|
||||
return RayNeoLink(conn, endpoints, listener, RayNeoProtocol.isGt(device.vendorId, device.productId), loadCalibration).also { link ->
|
||||
link.worker = Thread({ link.run() }, "rayneo-imu").apply { isDaemon = true }
|
||||
link.worker.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
object RayNeoModel {
|
||||
private val modelName = Regex("(?i)(ray\\s*neo|tcl).*\\b(air|gt)(?=\\b|[0-9])")
|
||||
|
||||
fun isVendor(vid: Int) = vid == RayNeoProtocol.VENDOR_ID ||
|
||||
vid == RayNeoProtocol.GT_VENDOR_ID || vid == 0x22d9
|
||||
|
||||
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
|
||||
if (!isVendor(vid)) return null
|
||||
// GT's identity is confirmed by verncat's SDK; do not claim other products
|
||||
// from that vendor just because their name resembles a supported model.
|
||||
val gt = RayNeoProtocol.isGt(vid, pid)
|
||||
if (vid == RayNeoProtocol.GT_VENDOR_ID && !gt) return null
|
||||
if (!(vid == RayNeoProtocol.VENDOR_ID && pid == RayNeoProtocol.PRODUCT_ID) &&
|
||||
!gt &&
|
||||
!modelName.containsMatchIn(name.orEmpty())) return null
|
||||
return Model(name?.trim()?.takeIf { modelName.containsMatchIn(it) }
|
||||
?: if (gt) "RayNeo GT series" else "RayNeo Air")
|
||||
}
|
||||
|
||||
fun isDisplay(name: String): Boolean =
|
||||
name.contains("rayneo", ignoreCase = true) || name.contains("ray neo", ignoreCase = true) ||
|
||||
modelName.containsMatchIn(name)
|
||||
|
||||
// Observed on GT Max: Android exposes an unbranded EDID monitor name.
|
||||
// Match its product metadata, not every display called SmartGlasses/TCL.
|
||||
fun isDisplayProduct(name: String?, manufacturer: String?, productId: String?): Boolean =
|
||||
name?.trim().equals("SmartGlasses", ignoreCase = true) &&
|
||||
manufacturer.equals("TCL", ignoreCase = true) && productId == "980"
|
||||
|
||||
private class Model(override val name: String) : GlassesModel {
|
||||
override val controllable = true
|
||||
override val brightnessMax = 0
|
||||
override val volumeMax = 0
|
||||
override val diagonalFovDeg = 46f // Conservative existing default, not a calibrated model profile.
|
||||
override val sbs90 = false
|
||||
override fun mode(hz: Int, sbs: Boolean) =
|
||||
if (sbs) RayNeoProtocol.CMD_SWITCH_TO_3D else RayNeoProtocol.CMD_SWITCH_TO_2D
|
||||
override fun modeForStep(step: Int): Int? = null
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0).
|
||||
// See docs/third-party/rayneo/. Returns scaffold quaternions in (w, x, y, z) order.
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal data class Vec3(val x: Float, val y: Float, val z: Float) {
|
||||
fun length(): Float = sqrt(x * x + y * y + z * z)
|
||||
|
||||
fun normalized(): Vec3 {
|
||||
val n = length()
|
||||
if (n < 1e-6f) {
|
||||
return Vec3(0f, 0f, 0f)
|
||||
}
|
||||
return Vec3(x / n, y / n, z / n)
|
||||
}
|
||||
|
||||
fun cross(other: Vec3): Vec3 {
|
||||
return Vec3(
|
||||
y * other.z - z * other.y,
|
||||
z * other.x - x * other.z,
|
||||
x * other.y - y * other.x
|
||||
)
|
||||
}
|
||||
|
||||
operator fun plus(other: Vec3): Vec3 = Vec3(x + other.x, y + other.y, z + other.z)
|
||||
operator fun minus(other: Vec3): Vec3 = Vec3(x - other.x, y - other.y, z - other.z)
|
||||
operator fun times(scale: Float): Vec3 = Vec3(x * scale, y * scale, z * scale)
|
||||
}
|
||||
|
||||
internal data class Quaternion(val w: Float, val x: Float, val y: Float, val z: Float) {
|
||||
operator fun times(other: Quaternion): Quaternion {
|
||||
return Quaternion(
|
||||
w = w * other.w - x * other.x - y * other.y - z * other.z,
|
||||
x = w * other.x + x * other.w + y * other.z - z * other.y,
|
||||
y = w * other.y - x * other.z + y * other.w + z * other.x,
|
||||
z = w * other.z + x * other.y - y * other.x + z * other.w
|
||||
)
|
||||
}
|
||||
|
||||
operator fun times(scale: Float): Quaternion {
|
||||
return Quaternion(w * scale, x * scale, y * scale, z * scale)
|
||||
}
|
||||
|
||||
fun normalized(): Quaternion {
|
||||
val n = sqrt(w * w + x * x + y * y + z * z)
|
||||
if (n < 1e-6f) {
|
||||
return IDENTITY
|
||||
}
|
||||
return Quaternion(w / n, x / n, y / n, z / n)
|
||||
}
|
||||
|
||||
fun conjugate(): Quaternion = Quaternion(w, -x, -y, -z)
|
||||
|
||||
fun rotate(v: Vec3): Vec3 {
|
||||
val qv = Quaternion(0f, v.x, v.y, v.z)
|
||||
val r = this * qv * conjugate()
|
||||
return Vec3(r.x, r.y, r.z)
|
||||
}
|
||||
|
||||
fun integrateBodyRate(omegaRadPerSec: Vec3, dtSec: Float): Quaternion {
|
||||
val omega = Quaternion(0f, omegaRadPerSec.x, omegaRadPerSec.y, omegaRadPerSec.z)
|
||||
val qDot = (this * omega) * 0.5f
|
||||
return Quaternion(
|
||||
w + qDot.w * dtSec,
|
||||
x + qDot.x * dtSec,
|
||||
y + qDot.y * dtSec,
|
||||
z + qDot.z * dtSec
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val IDENTITY = Quaternion(1f, 0f, 0f, 0f)
|
||||
|
||||
fun fromAxisAngle(axis: Vec3, angleRad: Float): Quaternion {
|
||||
val nAxis = axis.normalized()
|
||||
val half = angleRad * 0.5f
|
||||
val s = sin(half)
|
||||
val c = cos(half)
|
||||
return Quaternion(c, nAxis.x * s, nAxis.y * s, nAxis.z * s).normalized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RayNeoSensorProfile(val rotationXDeg: Float, val gtMaxAxes: Boolean) {
|
||||
companion object {
|
||||
fun forDevice(gtUsb: Boolean, boardId: Int) = when {
|
||||
gtUsb && boardId == RayNeoProtocol.BOARD_GT -> RayNeoSensorProfile(-25f, false)
|
||||
gtUsb -> RayNeoSensorProfile(35f, true)
|
||||
boardId == RayNeoProtocol.BOARD_AIR_4_PRO -> RayNeoSensorProfile(-20f, false)
|
||||
else -> RayNeoSensorProfile(0f, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class RayNeoSensorAxes(imuRotationXDeg: Float, private val gt: Boolean) {
|
||||
private val rotation = Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f),
|
||||
Math.toRadians((if (gt) 35f else imuRotationXDeg).toDouble()).toFloat())
|
||||
|
||||
fun map(v: Vec3): Vec3 = rotation.rotate(if (gt) Vec3(-v.y, v.z, -v.x) else v)
|
||||
}
|
||||
|
||||
internal class RayNeoOrientationFilter(
|
||||
imuRotationXDeg: Float,
|
||||
gt: Boolean = false,
|
||||
private val calibration: RayNeoCalibration? = null,
|
||||
) {
|
||||
private val worldUp = Vec3(0f, 1f, 0f)
|
||||
private val accelGain = 1.5f
|
||||
private val dpsToRad = (Math.PI.toFloat() / 180f)
|
||||
private val gravityMps2 = 9.81f
|
||||
private val stationaryAccelTol = 1.25f
|
||||
private val stationaryGyroRadPerSec = 0.18f
|
||||
private val gyroBiasUpdateHz = 0.5f
|
||||
private val axes = RayNeoSensorAxes(imuRotationXDeg, gt)
|
||||
|
||||
private var q = Quaternion.IDENTITY
|
||||
private var gravityInitialized = false
|
||||
private var gyroBias = Vec3(0f, 0f, 0f)
|
||||
private var lastTick100us: Long = -1L
|
||||
private var lastRealtimeNs: Long = 0L
|
||||
|
||||
companion object {
|
||||
/** Scaffold: right +X, up +Y, back +Z. VITURE: right -Y, up +Z, back -X.
|
||||
* Conjugate by HeadPose.GLASSES_AXES before entering the unchanged VITURE pose path.
|
||||
* This is a basis conversion, not a projection or seated-origin adjustment.
|
||||
*/
|
||||
fun glassesPose(wxyz: FloatArray): FloatArray =
|
||||
floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0])
|
||||
}
|
||||
|
||||
fun update(sample: RayNeoSensorSample): FloatArray {
|
||||
var accel = Vec3(sample.accelMps2[0], sample.accelMps2[1], sample.accelMps2[2])
|
||||
var gyro = Vec3(sample.gyroDps[0], sample.gyroDps[1], sample.gyroDps[2])
|
||||
calibration?.let { gyro -= it.gyroBiasDps }
|
||||
accel = axes.map(accel)
|
||||
gyro = axes.map(gyro) * dpsToRad
|
||||
calibration?.let {
|
||||
accel = it.level.rotate(accel)
|
||||
gyro = it.level.rotate(gyro)
|
||||
}
|
||||
|
||||
val nowNs = System.nanoTime()
|
||||
var dtSec = 0.01f
|
||||
if (lastTick100us >= 0L && sample.deviceTick100us != lastTick100us) {
|
||||
dtSec = (((sample.deviceTick100us - lastTick100us) and 0xffffffffL).toFloat() * 1e-4f)
|
||||
} else if (lastRealtimeNs > 0L && nowNs > lastRealtimeNs) {
|
||||
dtSec = ((nowNs - lastRealtimeNs).toFloat() * 1e-9f)
|
||||
}
|
||||
if (dtSec < 0.001f || dtSec > 0.1f) {
|
||||
dtSec = 0.01f
|
||||
}
|
||||
lastTick100us = sample.deviceTick100us
|
||||
lastRealtimeNs = nowNs
|
||||
|
||||
val accelNorm = accel.length()
|
||||
val stationary =
|
||||
kotlin.math.abs(accelNorm - gravityMps2) < stationaryAccelTol &&
|
||||
gyro.length() < stationaryGyroRadPerSec
|
||||
if (stationary) {
|
||||
val alpha = minOf(1f, dtSec * gyroBiasUpdateHz)
|
||||
gyroBias = gyroBias * (1f - alpha) + gyro * alpha
|
||||
}
|
||||
gyro -= gyroBias
|
||||
|
||||
if (accelNorm > 1e-3f) {
|
||||
val measuredUp = accel.normalized()
|
||||
if (!gravityInitialized) {
|
||||
// Align gravity before publishing the first pose. Starting at
|
||||
// identity and settling after recenter can rotate the horizon.
|
||||
val axis = measuredUp.cross(worldUp)
|
||||
q = if (measuredUp.y < -0.9999f) {
|
||||
Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat())
|
||||
} else {
|
||||
Quaternion(1f + measuredUp.y, axis.x, axis.y, axis.z).normalized()
|
||||
}
|
||||
gravityInitialized = true
|
||||
}
|
||||
val predictedUp = q.conjugate().rotate(worldUp).normalized()
|
||||
// q maps body to world: measured x predicted reduces gravity error.
|
||||
val correction = measuredUp.cross(predictedUp)
|
||||
gyro += correction * accelGain
|
||||
}
|
||||
|
||||
q = q.integrateBodyRate(gyro, dtSec).normalized()
|
||||
return floatArrayOf(q.w, q.x, q.y, q.z)
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0).
|
||||
// See docs/third-party/rayneo/ for license and protocol attribution.
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import java.util.Arrays
|
||||
|
||||
private fun Byte.u(): Int = toInt() and 0xFF
|
||||
|
||||
data class RayNeoSensorSample(
|
||||
val accelMps2: FloatArray,
|
||||
val gyroDps: FloatArray,
|
||||
val temperatureC: Float,
|
||||
val magnet: FloatArray,
|
||||
val proximity: Float,
|
||||
val light: Float,
|
||||
val deviceTick100us: Long
|
||||
)
|
||||
|
||||
data class RayNeoDeviceInfo(
|
||||
val boardId: Int,
|
||||
val sideBySideEnabled: Boolean
|
||||
)
|
||||
|
||||
sealed interface RayNeoPacket {
|
||||
data class Sensor(val sample: RayNeoSensorSample) : RayNeoPacket
|
||||
data class Response(val cmd: Int, val raw: ByteArray) : RayNeoPacket
|
||||
data object Unknown : RayNeoPacket
|
||||
}
|
||||
|
||||
data class RayNeoEndpointSelection(
|
||||
val usbInterface: UsbInterface,
|
||||
val inEndpoint: UsbEndpoint,
|
||||
val outEndpoint: UsbEndpoint
|
||||
)
|
||||
|
||||
object RayNeoProtocol {
|
||||
const val VENDOR_ID = 0x1BBB
|
||||
const val PRODUCT_ID = 0xAF50
|
||||
// verncat/RayNeo-Air-3S-Pro-OpenVR PR #3 (MIT), tested on GT hardware.
|
||||
const val GT_VENDOR_ID = 0x3941
|
||||
private const val GT_CONTROL_INTERFACE = 5
|
||||
|
||||
fun isGt(vid: Int, pid: Int) = vid == GT_VENDOR_ID && pid == PRODUCT_ID
|
||||
|
||||
const val BOARD_AIR_4_PRO = 0x3A
|
||||
const val BOARD_GT = 0x40 // Observed on the user's non-Max GT.
|
||||
|
||||
const val PKT_MAGIC = 0x99
|
||||
const val PKT_SENSOR = 0x65
|
||||
const val PKT_RESPONSE = 0xC8
|
||||
|
||||
const val CMD_ACQUIRE_DEVICE_INFO = 0
|
||||
const val CMD_OPEN_IMU = 1
|
||||
const val CMD_CLOSE_IMU = 2
|
||||
const val CMD_SWITCH_TO_3D = 6
|
||||
const val CMD_SWITCH_TO_2D = 7
|
||||
|
||||
private const val RESPONSE_CMD_OFFSET = 8
|
||||
|
||||
private const val ACCEL_OFFSET = 4
|
||||
private const val GYRO_OFFSET = 16
|
||||
private const val TEMP_OFFSET = 28
|
||||
private const val MAG_X_OFFSET = 32
|
||||
private const val TICK_OFFSET = 40
|
||||
private const val PSENSOR_OFFSET = 44
|
||||
private const val LSENSOR_OFFSET = 48
|
||||
private const val MAG_Z_OFFSET = 52
|
||||
|
||||
private const val DEVINFO_BOARD_ID_OFFSET = 21
|
||||
private const val DEVINFO_SIDE_BY_SIDE_OFFSET = 43
|
||||
|
||||
fun buildCommandPacket(cmd: Int, arg: Int, packetSize: Int): ByteArray {
|
||||
val len = maxOf(packetSize, 64)
|
||||
val pkt = ByteArray(len)
|
||||
pkt[0] = 0x66
|
||||
pkt[1] = cmd.toByte()
|
||||
pkt[2] = arg.toByte()
|
||||
return pkt
|
||||
}
|
||||
|
||||
fun classifyPacket(raw: ByteArray): RayNeoPacket {
|
||||
if (raw.size < 4 || raw[0].u() != PKT_MAGIC || raw[2].u() != raw.size) {
|
||||
return RayNeoPacket.Unknown
|
||||
}
|
||||
|
||||
return when (raw[1].u()) {
|
||||
PKT_SENSOR -> {
|
||||
val sample = parseSensorSample(raw)
|
||||
if (sample != null) RayNeoPacket.Sensor(sample) else RayNeoPacket.Unknown
|
||||
}
|
||||
PKT_RESPONSE -> {
|
||||
if (raw.size <= RESPONSE_CMD_OFFSET) {
|
||||
RayNeoPacket.Unknown
|
||||
} else {
|
||||
RayNeoPacket.Response(raw[RESPONSE_CMD_OFFSET].u(), raw)
|
||||
}
|
||||
}
|
||||
else -> RayNeoPacket.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fun parseDeviceInfo(raw: ByteArray): RayNeoDeviceInfo? {
|
||||
if (raw.size <= DEVINFO_SIDE_BY_SIDE_OFFSET || raw[2].u() != raw.size || raw[0].u() != PKT_MAGIC ||
|
||||
raw[1].u() != PKT_RESPONSE || raw[RESPONSE_CMD_OFFSET].u() != CMD_ACQUIRE_DEVICE_INFO) {
|
||||
return null
|
||||
}
|
||||
|
||||
return RayNeoDeviceInfo(
|
||||
boardId = raw[DEVINFO_BOARD_ID_OFFSET].u(),
|
||||
sideBySideEnabled = raw[DEVINFO_SIDE_BY_SIDE_OFFSET].u() != 0
|
||||
)
|
||||
}
|
||||
|
||||
fun selectEndpoints(device: UsbDevice): RayNeoEndpointSelection? {
|
||||
val gt = isGt(device.vendorId, device.productId)
|
||||
val gtCandidates = mutableListOf<RayNeoEndpointSelection>()
|
||||
var fallback: RayNeoEndpointSelection? = null
|
||||
|
||||
for (i in 0 until device.interfaceCount) {
|
||||
val usbInterface = device.getInterface(i)
|
||||
var inEndpoint: UsbEndpoint? = null
|
||||
var outEndpoint: UsbEndpoint? = null
|
||||
|
||||
for (j in 0 until usbInterface.endpointCount) {
|
||||
val endpoint = usbInterface.getEndpoint(j)
|
||||
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_INT &&
|
||||
endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||
continue
|
||||
}
|
||||
// Exclude small button reports from GT's protocol transport.
|
||||
if (gt && endpoint.maxPacketSize < 64) continue
|
||||
|
||||
if (endpoint.direction == UsbConstants.USB_DIR_IN && inEndpoint == null) {
|
||||
inEndpoint = endpoint
|
||||
} else if (endpoint.direction == UsbConstants.USB_DIR_OUT && outEndpoint == null) {
|
||||
outEndpoint = endpoint
|
||||
}
|
||||
}
|
||||
|
||||
if (inEndpoint != null && outEndpoint != null) {
|
||||
val selection = RayNeoEndpointSelection(usbInterface, inEndpoint, outEndpoint)
|
||||
if (gt) {
|
||||
if (usbInterface.id == GT_CONTROL_INTERFACE) return selection
|
||||
gtCandidates += selection
|
||||
continue
|
||||
}
|
||||
if (inEndpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT &&
|
||||
outEndpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT
|
||||
) {
|
||||
return selection
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = selection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interface numbers can change with firmware. Use a unique protocol-sized
|
||||
// pair when interface 5 is absent, never an arbitrary composite interface.
|
||||
return if (gt) gtCandidates.singleOrNull() else fallback
|
||||
}
|
||||
|
||||
private fun parseSensorSample(raw: ByteArray): RayNeoSensorSample? {
|
||||
if (raw.size < MAG_Z_OFFSET + 4 ||
|
||||
(ACCEL_OFFSET until TEMP_OFFSET step 4).any { !readF32LE(raw, it).isFinite() }) {
|
||||
return null
|
||||
}
|
||||
|
||||
return RayNeoSensorSample(
|
||||
accelMps2 = floatArrayOf(
|
||||
readF32LE(raw, ACCEL_OFFSET + 0),
|
||||
readF32LE(raw, ACCEL_OFFSET + 4),
|
||||
readF32LE(raw, ACCEL_OFFSET + 8)
|
||||
),
|
||||
gyroDps = floatArrayOf(
|
||||
readF32LE(raw, GYRO_OFFSET + 0),
|
||||
readF32LE(raw, GYRO_OFFSET + 4),
|
||||
readF32LE(raw, GYRO_OFFSET + 8)
|
||||
),
|
||||
temperatureC = readF32LE(raw, TEMP_OFFSET),
|
||||
magnet = floatArrayOf(
|
||||
readF32LE(raw, MAG_X_OFFSET),
|
||||
readF32LE(raw, MAG_X_OFFSET + 4),
|
||||
readF32LE(raw, MAG_Z_OFFSET)
|
||||
),
|
||||
proximity = readF32LE(raw, PSENSOR_OFFSET),
|
||||
light = readF32LE(raw, LSENSOR_OFFSET),
|
||||
deviceTick100us = readU32LE(raw, TICK_OFFSET).toLong() and 0xffffffffL
|
||||
)
|
||||
}
|
||||
|
||||
private fun readU32LE(raw: ByteArray, offset: Int): Int {
|
||||
val b0 = raw[offset].u()
|
||||
val b1 = raw[offset + 1].u()
|
||||
val b2 = raw[offset + 2].u()
|
||||
val b3 = raw[offset + 3].u()
|
||||
return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
|
||||
}
|
||||
|
||||
private fun readF32LE(raw: ByteArray, offset: Int): Float {
|
||||
return Float.fromBits(readU32LE(raw, offset))
|
||||
}
|
||||
}
|
||||
|
||||
class RayNeoPacketAssembler {
|
||||
private val stash = ByteArray(4096)
|
||||
private var len = 0
|
||||
|
||||
fun append(data: ByteArray, count: Int) {
|
||||
require(count in 0..data.size)
|
||||
if (count <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (count >= stash.size || len + count > stash.size) {
|
||||
len = 0
|
||||
}
|
||||
|
||||
val copyLen = minOf(count, stash.size - len)
|
||||
System.arraycopy(data, 0, stash, len, copyLen)
|
||||
len += copyLen
|
||||
}
|
||||
|
||||
fun nextPacket(): ByteArray? {
|
||||
var offset = 0
|
||||
while (offset + 4 <= len) {
|
||||
if (stash[offset].u() != RayNeoProtocol.PKT_MAGIC) {
|
||||
offset++
|
||||
continue
|
||||
}
|
||||
|
||||
val packetLen = stash[offset + 2].u()
|
||||
if (packetLen < 4 || packetLen > 64 ||
|
||||
stash[offset + 1].u() !in listOf(RayNeoProtocol.PKT_SENSOR, RayNeoProtocol.PKT_RESPONSE)) {
|
||||
offset++
|
||||
continue
|
||||
}
|
||||
if (offset + packetLen > len) {
|
||||
break
|
||||
}
|
||||
|
||||
val packet = Arrays.copyOfRange(stash, offset, offset + packetLen)
|
||||
compact(offset + packetLen)
|
||||
return packet
|
||||
}
|
||||
|
||||
if (offset > 0) {
|
||||
compact(offset)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun compact(consumed: Int) {
|
||||
if (consumed <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (consumed >= len) {
|
||||
len = 0
|
||||
return
|
||||
}
|
||||
|
||||
System.arraycopy(stash, consumed, stash, 0, len - consumed)
|
||||
len -= consumed
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal class VitureLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
private val mcu: UsbInterface,
|
||||
private val mcuOut: UsbEndpoint,
|
||||
private val mcuIn: UsbEndpoint?,
|
||||
private val imu: UsbInterface?,
|
||||
private val imuIn: UsbEndpoint?,
|
||||
private val listener: Listener,
|
||||
) : GlassesLink {
|
||||
interface Listener {
|
||||
fun onFrame(frame: VitureProtocol.Frame)
|
||||
fun onImu(payload: ByteArray, timeNs: Long)
|
||||
fun onLost()
|
||||
}
|
||||
|
||||
@Volatile private var running = true
|
||||
private val writeLock = Any()
|
||||
private var lastSendAt = 0L
|
||||
private val readers = mutableListOf<Thread>()
|
||||
private val bad = AtomicLong()
|
||||
|
||||
override val hasImu: Boolean get() = imuIn != null
|
||||
override val badFrames: Long get() = bad.get()
|
||||
|
||||
private fun startReaders() {
|
||||
mcuIn?.let { ep -> readers += reader("viture-mcu", ep) { buf, n -> onPacket(buf, n, false) } }
|
||||
imuIn?.let { ep ->
|
||||
readers += reader("viture-imu", ep) { buf, n -> onPacket(buf, n, true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPacket(buf: ByteArray, size: Int, imuEndpoint: Boolean) {
|
||||
val frame = VitureProtocol.parse(buf, size) ?: return
|
||||
if (!frame.crcOk) {
|
||||
if (bad.incrementAndGet() == 1L) Log.w(TAG, "dropping frames with a bad checksum")
|
||||
return
|
||||
}
|
||||
if (imuEndpoint && frame.magic == VitureProtocol.MAGIC_IMU)
|
||||
listener.onImu(frame.payload, SystemClock.elapsedRealtimeNanos())
|
||||
else listener.onFrame(frame)
|
||||
}
|
||||
|
||||
private fun reader(name: String, ep: UsbEndpoint, onPacket: (ByteArray, Int) -> Unit): Thread =
|
||||
Thread({
|
||||
val buf = ByteArray(VitureProtocol.PACKET_SIZE)
|
||||
var failures = 0
|
||||
while (running) {
|
||||
val started = SystemClock.uptimeMillis()
|
||||
val n = connection.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS)
|
||||
if (!running) break
|
||||
if (n > 0) {
|
||||
failures = 0
|
||||
runCatching { onPacket(buf, n) }.onFailure { Log.w(TAG, "$name packet", it) }
|
||||
} else if (n < 0 && SystemClock.uptimeMillis() - started < READ_TIMEOUT_MS / 2) {
|
||||
failures++
|
||||
SystemClock.sleep(2)
|
||||
if (failures >= LOST_AFTER) {
|
||||
Log.w(TAG, "$name: reads keep failing, link lost")
|
||||
if (running) listener.onLost()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, name).apply { isDaemon = true; priority = Thread.MAX_PRIORITY; start() }
|
||||
|
||||
override fun send(msgId: Int, payload: ByteArray): Boolean {
|
||||
val pkt = VitureProtocol.packet(msgId, payload)
|
||||
val n = synchronized(writeLock) {
|
||||
if (!running) return false
|
||||
val wait = lastSendAt + COMMAND_GAP_MS - SystemClock.uptimeMillis()
|
||||
if (wait > 0) SystemClock.sleep(wait)
|
||||
connection.bulkTransfer(mcuOut, pkt, pkt.size, WRITE_TIMEOUT_MS).also { lastSendAt = SystemClock.uptimeMillis() }
|
||||
}
|
||||
Log.i(TAG, "send 0x%04X [%s] -> %d".format(msgId, payload.joinToString("") { "%02X".format(it) }, n))
|
||||
return n == pkt.size
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(writeLock) { running = false }
|
||||
val self = Thread.currentThread()
|
||||
readers.forEach { if (it !== self) runCatching { it.join(READ_TIMEOUT_MS * 4L) } }
|
||||
runCatching { connection.releaseInterface(mcu) }
|
||||
imu?.let { runCatching { connection.releaseInterface(it) } }
|
||||
runCatching { connection.close() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VitureLink"
|
||||
private const val READ_TIMEOUT_MS = 100
|
||||
private const val WRITE_TIMEOUT_MS = 250
|
||||
private const val LOST_AFTER = 50
|
||||
const val COMMAND_GAP_MS = 60L
|
||||
|
||||
fun open(usb: UsbManager, device: UsbDevice, listener: Listener): VitureLink? {
|
||||
val hid = (0 until device.interfaceCount).map(device::getInterface)
|
||||
.filter { it.interfaceClass == UsbConstants.USB_CLASS_HID }
|
||||
.sortedBy { it.id }
|
||||
for (intf in hid) Log.i(TAG, "iface ${intf.id} eps " + (0 until intf.endpointCount).joinToString {
|
||||
val ep = intf.getEndpoint(it)
|
||||
"0x%02X".format(ep.address)
|
||||
})
|
||||
val mcu = hid.lastOrNull { it.outEndpoint() != null } ?: return null
|
||||
val imu = hid.firstOrNull { it.id != mcu.id && it.inEndpoint() != null }
|
||||
val conn = usb.openDevice(device) ?: return null
|
||||
if (!conn.claimInterface(mcu, true)) {
|
||||
conn.close()
|
||||
return null
|
||||
}
|
||||
val imuClaimed = imu != null && conn.claimInterface(imu, true)
|
||||
val link = VitureLink(conn, mcu, mcu.outEndpoint()!!, mcu.inEndpoint(),
|
||||
imu.takeIf { imuClaimed }, imu.takeIf { imuClaimed }?.inEndpoint(), listener)
|
||||
link.startReaders()
|
||||
return link
|
||||
}
|
||||
|
||||
private fun UsbInterface.outEndpoint(): UsbEndpoint? = (0 until endpointCount).map(::getEndpoint)
|
||||
.firstOrNull { it.direction == UsbConstants.USB_DIR_OUT && it.type == UsbConstants.USB_ENDPOINT_XFER_INT }
|
||||
|
||||
private fun UsbInterface.inEndpoint(): UsbEndpoint? = (0 until endpointCount).map(::getEndpoint)
|
||||
.firstOrNull { it.direction == UsbConstants.USB_DIR_IN && it.type == UsbConstants.USB_ENDPOINT_XFER_INT }
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
object VitureProtocol {
|
||||
const val VENDOR_ID = 0x35CA
|
||||
const val PACKET_SIZE = 64
|
||||
private const val HEADER = 0x12
|
||||
|
||||
const val MAGIC_COMMAND = 0xFE
|
||||
const val MAGIC_REPORT = 0xFD
|
||||
const val MAGIC_IMU = 0xFC
|
||||
|
||||
const val MSG_GET_BRIGHTNESS = 0x0005
|
||||
const val MSG_BRIGHTNESS = 0x0006
|
||||
const val MSG_GET_DISPLAY = 0x0007
|
||||
const val MSG_DISPLAY = 0x0008
|
||||
const val MSG_FILM = 0x000E
|
||||
const val MSG_IMU = 0x0015
|
||||
const val MSG_GET_IMU = 0x0017
|
||||
const val MSG_IMU_RATE = 0x0018
|
||||
const val MSG_GET_VOLUME = 0x0032
|
||||
const val MSG_VOLUME = 0x0033
|
||||
|
||||
const val EVENT_BRIGHTNESS = 0x0301
|
||||
const val EVENT_DISPLAY = 0x0302
|
||||
const val EVENT_VOLUME = 0x0304
|
||||
const val EVENT_DISPLAY_STEP = 0x030D
|
||||
|
||||
const val IMU_RATE_240 = 3
|
||||
|
||||
enum class Family { ONE, PRO, LUMA, ULTRA, BEAST, PRO2 }
|
||||
|
||||
class Model(override val name: String, val family: Family) : GlassesModel {
|
||||
override val controllable: Boolean get() = true
|
||||
val needsSdk: Boolean get() = family == Family.ULTRA || family == Family.BEAST || family == Family.PRO2
|
||||
val tall: Boolean get() = family == Family.LUMA || family == Family.ULTRA || family == Family.BEAST
|
||||
override val sbs90: Boolean get() = family == Family.ULTRA
|
||||
override val brightnessMax: Int get() = 8
|
||||
override val volumeMax: Int get() = if (family == Family.BEAST) 15 else 8
|
||||
override val hasSunblock: Boolean get() = family != Family.PRO2
|
||||
override val diagonalFovDeg: Float get() = when (family) {
|
||||
Family.ONE -> 43f
|
||||
Family.PRO -> 46f
|
||||
Family.PRO2, Family.LUMA, Family.ULTRA -> 50f
|
||||
Family.BEAST -> 58f
|
||||
}
|
||||
|
||||
override fun mode(hz: Int, sbs: Boolean): Int = when {
|
||||
tall && sbs -> if (hz >= 90 && sbs90) 0x45 else 0x42
|
||||
tall -> when { hz >= 120 -> 0x44; hz >= 90 -> 0x43; else -> 0x41 }
|
||||
sbs -> if (hz >= 90 && sbs90) 0x35 else 0x32
|
||||
else -> when { hz >= 120 -> 0x34; hz >= 90 -> 0x33; else -> 0x31 }
|
||||
}
|
||||
|
||||
override fun modeForStep(step: Int): Int? = if (tall) null else when (step) {
|
||||
1 -> 0x31
|
||||
2 -> 0x33
|
||||
3 -> 0x34
|
||||
4 -> 0x32
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** SDK validates the PID before returning this market name. Never guess a new PID. */
|
||||
fun sdkModel(marketName: String): Model? {
|
||||
val family = when (marketName) {
|
||||
"One", "Lite" -> Family.ONE
|
||||
"Pro" -> Family.PRO
|
||||
"Pro 2" -> Family.PRO2
|
||||
"Luma", "Luma Pro", "Luma Cyber" -> Family.LUMA
|
||||
"Luma Ultra" -> Family.ULTRA
|
||||
"Beast" -> Family.BEAST
|
||||
else -> return null
|
||||
}
|
||||
return Model("VITURE $marketName", family)
|
||||
}
|
||||
|
||||
fun model(productId: Int, productName: String?): Model? {
|
||||
val family = when (productId) {
|
||||
0x1011, 0x1013, 0x1015, 0x1017, 0x101b -> Family.ONE
|
||||
0x1019, 0x101d -> Family.PRO
|
||||
0x1121, 0x1131, 0x1141, 0x1151 -> Family.LUMA
|
||||
0x1101, 0x1104 -> Family.ULTRA
|
||||
0x1201, 0x1211 -> Family.BEAST
|
||||
else -> return null
|
||||
}
|
||||
val fallback = when (productId) {
|
||||
0x1015, 0x101b -> "VITURE One Lite"
|
||||
0x1011, 0x1013, 0x1017 -> "VITURE One"
|
||||
0x1019 -> "VITURE Pro"
|
||||
0x101d -> "VITURE Pro XR"
|
||||
0x1131 -> "VITURE Luma"
|
||||
0x1121, 0x1141, 0x1151 -> "VITURE Luma Pro"
|
||||
0x1101, 0x1104 -> "VITURE Luma Ultra"
|
||||
else -> "VITURE Beast"
|
||||
}
|
||||
val name = productName?.trim()?.removeSuffix(" Glasses")?.takeIf { it.startsWith("VITURE", ignoreCase = true) } ?: fallback
|
||||
return Model(name, family)
|
||||
}
|
||||
|
||||
private val CRC_TABLE = IntArray(256) { i ->
|
||||
var c = i shl 8
|
||||
repeat(8) { c = if (c and 0x8000 != 0) (c shl 1) xor 0x1021 else c shl 1 }
|
||||
c and 0xFFFF
|
||||
}
|
||||
|
||||
fun crc16(data: ByteArray, offset: Int, length: Int): Int {
|
||||
var crc = 0
|
||||
for (i in offset until offset + length) {
|
||||
crc = (CRC_TABLE[((data[i].toInt() and 0xFF) xor (crc ushr 8)) and 0xFF] xor (crc shl 8)) and 0xFFFF
|
||||
}
|
||||
return crc
|
||||
}
|
||||
|
||||
fun packet(msgId: Int, payload: ByteArray = ByteArray(0)): ByteArray {
|
||||
require(payload.size <= PACKET_SIZE - HEADER)
|
||||
val buf = ByteArray(PACKET_SIZE)
|
||||
buf[0] = 0xFF.toByte()
|
||||
buf[1] = MAGIC_COMMAND.toByte()
|
||||
val len = payload.size + 0x0C
|
||||
buf[4] = len.toByte()
|
||||
buf[5] = (len shr 8).toByte()
|
||||
buf[14] = msgId.toByte()
|
||||
buf[15] = (msgId shr 8).toByte()
|
||||
payload.copyInto(buf, HEADER)
|
||||
val crc = crc16(buf, 4, len + 2)
|
||||
buf[2] = crc.toByte()
|
||||
buf[3] = (crc shr 8).toByte()
|
||||
return buf
|
||||
}
|
||||
|
||||
class Frame(val magic: Int, val msgId: Int, val payload: ByteArray, val crcOk: Boolean)
|
||||
|
||||
fun parse(buf: ByteArray, size: Int): Frame? {
|
||||
if (size < HEADER || size > buf.size || (buf[0].toInt() and 0xFF) != 0xFF) return null
|
||||
val magic = buf[1].toInt() and 0xFF
|
||||
if (magic != MAGIC_COMMAND && magic != MAGIC_REPORT && magic != MAGIC_IMU) return null
|
||||
val len = u16(buf, 4)
|
||||
if (len < 0x0C || len + 6 > size) return null
|
||||
val dataLen = len - 0x0C
|
||||
val crcOk = crc16(buf, 4, len + 2) == u16(buf, 2)
|
||||
return Frame(magic, u16(buf, 14), buf.copyOfRange(HEADER, HEADER + dataLen), crcOk)
|
||||
}
|
||||
|
||||
private fun u16(buf: ByteArray, at: Int) = (buf[at].toInt() and 0xFF) or ((buf[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
fun beFloat(b: ByteArray, at: Int): Float = java.lang.Float.intBitsToFloat(
|
||||
((b[at].toInt() and 0xFF) shl 24) or ((b[at + 1].toInt() and 0xFF) shl 16) or
|
||||
((b[at + 2].toInt() and 0xFF) shl 8) or (b[at + 3].toInt() and 0xFF),
|
||||
)
|
||||
|
||||
fun imuQuaternion(payload: ByteArray): FloatArray? {
|
||||
if (payload.size >= 36) {
|
||||
val w = beFloat(payload, 20)
|
||||
val x = beFloat(payload, 24)
|
||||
val y = beFloat(payload, 28)
|
||||
val z = beFloat(payload, 32)
|
||||
val n = sqrt(w * w + x * x + y * y + z * z)
|
||||
if (n.isFinite() && n > 0.9f && n < 1.1f) return floatArrayOf(x / n, y / n, z / n, w / n)
|
||||
}
|
||||
if (payload.size < 12) return null
|
||||
val roll = beFloat(payload, 0)
|
||||
val pitch = beFloat(payload, 4)
|
||||
val yaw = beFloat(payload, 8)
|
||||
if (!roll.isFinite() || !pitch.isFinite() || !yaw.isFinite()) return null
|
||||
return eulerZxy(roll, pitch, yaw)
|
||||
}
|
||||
|
||||
fun eulerZxy(rollDeg: Float, pitchDeg: Float, yawDeg: Float): FloatArray {
|
||||
val r = Math.toRadians(rollDeg.toDouble()) / 2
|
||||
val p = Math.toRadians(pitchDeg.toDouble()) / 2
|
||||
val y = Math.toRadians(yawDeg.toDouble()) / 2
|
||||
val cx = cos(r); val sx = sin(r)
|
||||
val cy = cos(p); val sy = sin(p)
|
||||
val cz = cos(y); val sz = sin(y)
|
||||
return floatArrayOf(
|
||||
(sx * cy * cz - cx * sy * sz).toFloat(),
|
||||
(cx * sy * cz + sx * cy * sz).toFloat(),
|
||||
(cx * cy * sz + sx * sy * cz).toFloat(),
|
||||
(cx * cy * cz - sx * sy * sz).toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.util.Log
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** JNI adapter to VITURE's Android C SDK. The vendor binary is a local build input. */
|
||||
internal object VitureSdk {
|
||||
val available: Boolean by lazy {
|
||||
runCatching {
|
||||
System.loadLibrary("droiddeck_viture")
|
||||
nativeAvailable()
|
||||
}.onFailure { Log.w("VitureSdk", "VITURE driver unavailable", it) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun marketName(pid: Int): String? = if (available) nativeMarketName(pid) else null
|
||||
external fun nativeAvailable(): Boolean
|
||||
external fun nativeMarketName(pid: Int): String?
|
||||
external fun nativeOpen(pid: Int, fd: Int, cacheDirectory: String): Long
|
||||
external fun nativeType(token: Long): Int
|
||||
external fun nativeTracking(token: Long, enabled: Boolean): Int
|
||||
external fun nativeCommand(token: Long, command: Int, value: Int): Int
|
||||
/** Returns CLOCK_BOOTTIME receipt time, zero for no new pose, or a negative SDK error. */
|
||||
external fun nativePose(token: Long, wxyz: FloatArray): Long
|
||||
external fun nativeNextState(token: Long, event: IntArray): Int
|
||||
external fun nativeClose(token: Long)
|
||||
|
||||
const val CARINA = 2
|
||||
const val GET_DISPLAY = 1
|
||||
const val SET_DISPLAY = 2
|
||||
const val GET_BRIGHTNESS = 3
|
||||
const val SET_BRIGHTNESS = 4
|
||||
const val GET_VOLUME = 5
|
||||
const val SET_VOLUME = 6
|
||||
const val SET_FILM = 7
|
||||
}
|
||||
|
||||
internal object VitureSdkPose {
|
||||
// Pro 2 hardware captures use OpenGL axes despite the generic Gen2 callback's
|
||||
// NWU documentation. Keep that model-specific exception out of other Gen2 devices.
|
||||
fun usesOpenGlAxes(carina: Boolean, family: VitureProtocol.Family?) =
|
||||
carina || family == VitureProtocol.Family.PRO2
|
||||
|
||||
/** SDK quaternions are wxyz; normalize their coordinate basis at the boundary. */
|
||||
fun toGlasses(wxyz: FloatArray, openGlAxes: Boolean): FloatArray? {
|
||||
if (wxyz.size != 4 || wxyz.any { !it.isFinite() }) return null
|
||||
val length = sqrt(wxyz.sumOf { it.toDouble() * it }.toFloat())
|
||||
if (!length.isFinite() || length < 0.5f || length > 1.5f) return null
|
||||
val q = if (openGlAxes) floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0])
|
||||
else floatArrayOf(wxyz[1], wxyz[2], wxyz[3], wxyz[0])
|
||||
return q.map { it / length }.toFloatArray()
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/** SDK owns interface selection (including Ultra bulk endpoints) and all USB I/O. */
|
||||
internal class VitureSdkLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
private val token: Long,
|
||||
private val listener: Listener,
|
||||
family: VitureProtocol.Family?,
|
||||
) : GlassesLink {
|
||||
interface Listener {
|
||||
fun onValue(msgId: Int, value: Int)
|
||||
fun onPose(q: FloatArray, timeNs: Long)
|
||||
fun onLost()
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
@Volatile private var running = true
|
||||
@Volatile private var tracking = false
|
||||
override val ready: Boolean get() = running
|
||||
override val hasImu = true
|
||||
private val openGlAxes = VitureSdkPose.usesOpenGlAxes(VitureSdk.nativeType(token) == VitureSdk.CARINA, family)
|
||||
private val worker = Thread({ poll() }, "viture-sdk-pose").apply { isDaemon = true; start() }
|
||||
|
||||
override fun send(msgId: Int, payload: ByteArray): Boolean = synchronized(lock) {
|
||||
if (!running) return false
|
||||
val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: 0
|
||||
when (msgId) {
|
||||
VitureProtocol.MSG_IMU -> {
|
||||
val wanted = value != 0
|
||||
if (wanted == tracking) return true
|
||||
val rc = VitureSdk.nativeTracking(token, wanted)
|
||||
if (rc == 0 || !wanted) tracking = wanted
|
||||
return result("tracking", rc)
|
||||
}
|
||||
// Frequency is negotiated against SDK capabilities, including Pro 2's pose limit.
|
||||
VitureProtocol.MSG_IMU_RATE -> return tracking
|
||||
VitureProtocol.MSG_GET_IMU -> return true
|
||||
}
|
||||
val operation = when (msgId) {
|
||||
VitureProtocol.MSG_GET_DISPLAY -> VitureSdk.GET_DISPLAY
|
||||
VitureProtocol.MSG_DISPLAY -> VitureSdk.SET_DISPLAY
|
||||
VitureProtocol.MSG_GET_BRIGHTNESS -> VitureSdk.GET_BRIGHTNESS
|
||||
VitureProtocol.MSG_BRIGHTNESS -> VitureSdk.SET_BRIGHTNESS
|
||||
VitureProtocol.MSG_GET_VOLUME -> VitureSdk.GET_VOLUME
|
||||
VitureProtocol.MSG_VOLUME -> VitureSdk.SET_VOLUME
|
||||
VitureProtocol.MSG_FILM -> VitureSdk.SET_FILM
|
||||
else -> return false
|
||||
}
|
||||
val rc = VitureSdk.nativeCommand(token, operation, value)
|
||||
if (rc < 0) return result("command $operation", rc)
|
||||
if (operation == VitureSdk.GET_DISPLAY || operation == VitureSdk.GET_BRIGHTNESS || operation == VitureSdk.GET_VOLUME)
|
||||
listener.onValue(msgId, rc)
|
||||
else if (operation == VitureSdk.SET_DISPLAY) {
|
||||
val actual = VitureSdk.nativeCommand(token, VitureSdk.GET_DISPLAY, 0)
|
||||
if (actual >= 0) listener.onValue(VitureProtocol.MSG_GET_DISPLAY, actual)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
private fun result(operation: String, rc: Int): Boolean {
|
||||
if (rc < 0) Log.w("VitureSdkLink", "$operation failed: $rc")
|
||||
return rc == 0
|
||||
}
|
||||
|
||||
private fun poll() {
|
||||
val pose = FloatArray(4)
|
||||
val event = IntArray(2)
|
||||
var failures = 0
|
||||
while (running) {
|
||||
val timestamp = synchronized(lock) {
|
||||
if (running) repeat(6) {
|
||||
if (VitureSdk.nativeNextState(token, event) == 1) when (event[0]) {
|
||||
0 -> listener.onValue(VitureProtocol.MSG_GET_BRIGHTNESS, event[1])
|
||||
1 -> listener.onValue(VitureProtocol.MSG_GET_VOLUME, event[1])
|
||||
// A device event can use a different display-mode namespace.
|
||||
2 -> send(VitureProtocol.MSG_GET_DISPLAY)
|
||||
3 -> listener.onValue(VitureProtocol.MSG_FILM, event[1])
|
||||
}
|
||||
}
|
||||
if (!running || !tracking) 0L else VitureSdk.nativePose(token, pose)
|
||||
}
|
||||
if (timestamp > 0) {
|
||||
failures = 0
|
||||
VitureSdkPose.toGlasses(pose, openGlAxes)?.let { listener.onPose(it, timestamp) }
|
||||
} else if (timestamp == -2L || timestamp == -3L) {
|
||||
if (++failures >= 10) {
|
||||
Log.w("VitureSdkLink", "closing after $failures pose USB errors; last SDK result: $timestamp")
|
||||
listener.onLost()
|
||||
break
|
||||
}
|
||||
}
|
||||
SystemClock.sleep(if (tracking) 4 else 25)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(lock) {
|
||||
if (!running) return
|
||||
running = false
|
||||
VitureSdk.nativeClose(token)
|
||||
// Keep Java's descriptor alive until the SDK and its worker threads have stopped.
|
||||
connection.close()
|
||||
}
|
||||
if (Thread.currentThread() !== worker) worker.join(1000)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun open(usb: UsbManager, device: UsbDevice, cacheDirectory: File, listener: Listener,
|
||||
family: VitureProtocol.Family? = null): VitureSdkLink? {
|
||||
if (!VitureSdk.available) return null
|
||||
val conn = usb.openDevice(device) ?: return null
|
||||
val token = runCatching {
|
||||
cacheDirectory.mkdirs()
|
||||
VitureSdk.nativeOpen(device.productId, conn.fileDescriptor, cacheDirectory.absolutePath)
|
||||
}.onFailure { Log.w("VitureSdkLink", "SDK initialization failed", it) }.getOrDefault(0)
|
||||
if (token <= 0) { conn.close(); return null }
|
||||
return runCatching { VitureSdkLink(conn, token, listener, family) }.getOrElse {
|
||||
VitureSdk.nativeClose(token)
|
||||
conn.close()
|
||||
Log.w("VitureSdkLink", "SDK worker could not start", it)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.NetworkCapabilities
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
internal interface XrealChannels : Closeable {
|
||||
val controlInput: InputStream
|
||||
val controlOutput: OutputStream
|
||||
val reports: InputStream
|
||||
}
|
||||
|
||||
internal interface XrealConnector : Closeable {
|
||||
fun connect(): XrealChannels
|
||||
}
|
||||
|
||||
internal class XrealNetworkMissing : IOException("XREAL USB Ethernet network is not available")
|
||||
|
||||
/** Bind individual sockets, never the process: Steam must retain its Internet network. */
|
||||
internal class AndroidXrealConnector(context: Context) : XrealConnector {
|
||||
private val manager = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val address = InetAddress.getByName(XrealProtocol.HOST)
|
||||
private val lock = Any()
|
||||
private val sockets = mutableSetOf<Socket>()
|
||||
private var closed = false
|
||||
|
||||
companion object {
|
||||
internal fun hasDirectRoute(properties: LinkProperties, address: InetAddress): Boolean =
|
||||
properties.routes.any { !it.hasGateway() && it.destination.prefixLength > 0 && it.matches(address) }
|
||||
}
|
||||
|
||||
override fun connect(): XrealChannels {
|
||||
val network = manager.allNetworks.firstOrNull { network ->
|
||||
manager.getNetworkCapabilities(network)?.let {
|
||||
it.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) &&
|
||||
!it.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
} == true && manager.getLinkProperties(network)?.let { hasDirectRoute(it, address) } == true
|
||||
} ?: throw XrealNetworkMissing()
|
||||
fun open(port: Int): Socket {
|
||||
val socket = Socket()
|
||||
synchronized(lock) {
|
||||
if (closed) { socket.close(); throw IOException("XREAL connection closed") }
|
||||
sockets.add(socket)
|
||||
}
|
||||
try {
|
||||
network.bindSocket(socket)
|
||||
socket.soTimeout = 2000
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(address, port), 1500)
|
||||
return socket
|
||||
} catch (e: Exception) {
|
||||
release(socket)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
val control = open(XrealProtocol.CONTROL_PORT)
|
||||
val reportSocket = try { open(XrealProtocol.REPORT_PORT) } catch (e: Exception) {
|
||||
release(control)
|
||||
throw e
|
||||
}
|
||||
return object : XrealChannels {
|
||||
override val controlInput = control.getInputStream()
|
||||
override val controlOutput = control.getOutputStream()
|
||||
override val reports = reportSocket.getInputStream()
|
||||
override fun close() { release(control); release(reportSocket) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun release(socket: Socket) = synchronized(lock) {
|
||||
runCatching { socket.close() }
|
||||
sockets.remove(socket)
|
||||
Unit
|
||||
}
|
||||
|
||||
override fun close() = synchronized(lock) {
|
||||
closed = true
|
||||
sockets.forEach { runCatching { it.close() } }
|
||||
sockets.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ArrayBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** One connection owns two TCP channels. All control requests are serialized off the UI thread. */
|
||||
internal class XrealLink internal constructor(
|
||||
private val connector: XrealConnector,
|
||||
private val listener: Listener,
|
||||
) : GlassesLink {
|
||||
interface Listener {
|
||||
fun onReady()
|
||||
fun onValue(message: Int, value: Int)
|
||||
fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long)
|
||||
fun onUnavailable(networkMissing: Boolean)
|
||||
}
|
||||
|
||||
private data class Command(val message: Int, val value: Int)
|
||||
private val commands = ArrayBlockingQueue<Command>(16)
|
||||
private val connection = AtomicReference<XrealChannels?>()
|
||||
@Volatile private var running = true
|
||||
@Volatile private var tracking = false
|
||||
@Volatile override var ready = false
|
||||
private set
|
||||
override val hasImu = true
|
||||
private val worker = Thread(::run, "xreal-control").apply { isDaemon = true; start() }
|
||||
|
||||
companion object {
|
||||
fun open(context: Context, listener: Listener) = XrealLink(AndroidXrealConnector(context), listener)
|
||||
}
|
||||
|
||||
override fun send(msgId: Int, payload: ByteArray): Boolean {
|
||||
if (!running || !ready) return false
|
||||
val value = payload.firstOrNull()?.toInt()?.and(255) ?: -1
|
||||
if (msgId == VitureProtocol.MSG_IMU) {
|
||||
if (value !in 0..1) return false
|
||||
tracking = value == 1
|
||||
return true
|
||||
}
|
||||
if (msgId == VitureProtocol.MSG_GET_IMU) return true
|
||||
val valid = when (msgId) {
|
||||
VitureProtocol.MSG_GET_DISPLAY -> true
|
||||
VitureProtocol.MSG_DISPLAY -> value in 2..5 || value == XrealModel.NATIVE_60HZ
|
||||
VitureProtocol.MSG_BRIGHTNESS -> value in 0..9
|
||||
VitureProtocol.MSG_FILM -> value in 0..1
|
||||
else -> false // No guessed volume, sensor-rate, or VITURE wire commands.
|
||||
}
|
||||
return valid && commands.offer(Command(msgId, value))
|
||||
}
|
||||
|
||||
private fun run() {
|
||||
var lastError: String? = null
|
||||
while (running) {
|
||||
var channels: XrealChannels? = null
|
||||
var reports: Thread? = null
|
||||
val failure = AtomicReference<Exception?>()
|
||||
try {
|
||||
channels = connector.connect()
|
||||
connection.set(channels)
|
||||
if (!running) break
|
||||
val control = XrealControl(channels)
|
||||
listener.onValue(VitureProtocol.MSG_GET_DISPLAY, control.display())
|
||||
val current = channels
|
||||
reports = Thread({
|
||||
try {
|
||||
var filter = XrealOrientationFilter()
|
||||
var wasTracking = false
|
||||
var lastSensor = System.nanoTime()
|
||||
while (running && connection.get() === current) {
|
||||
val frame = XrealProtocol.read(current.reports)
|
||||
val sample = XrealProtocol.sample(frame)
|
||||
if (sample != null) {
|
||||
lastSensor = System.nanoTime()
|
||||
val enabled = tracking
|
||||
if (enabled && !wasTracking) filter = XrealOrientationFilter()
|
||||
wasTracking = enabled
|
||||
if (enabled) filter.update(sample)?.let {
|
||||
if (running && ready && tracking && connection.get() === current)
|
||||
listener.onPose(frame.body, it, System.nanoTime())
|
||||
}
|
||||
} else if (System.nanoTime() - lastSensor > 3_000_000_000L) {
|
||||
throw IOException("No valid XREAL IMU reports")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failure.set(e)
|
||||
current.close() // Wake a control read immediately.
|
||||
}
|
||||
}, "xreal-reports").apply { isDaemon = true; start() }
|
||||
ready = true
|
||||
lastError = null
|
||||
listener.onReady()
|
||||
var heartbeat = System.nanoTime()
|
||||
while (running && failure.get() == null) {
|
||||
val command = commands.poll(250, TimeUnit.MILLISECONDS)
|
||||
if (command != null) {
|
||||
when (command.message) {
|
||||
VitureProtocol.MSG_GET_DISPLAY ->
|
||||
listener.onValue(command.message, control.display())
|
||||
VitureProtocol.MSG_DISPLAY -> {
|
||||
listener.onValue(VitureProtocol.MSG_GET_DISPLAY, control.changeDisplay(command.value))
|
||||
}
|
||||
VitureProtocol.MSG_BRIGHTNESS -> {
|
||||
control.set(XrealProtocol.SET_BRIGHTNESS, command.value)
|
||||
listener.onValue(command.message, command.value)
|
||||
}
|
||||
VitureProtocol.MSG_FILM -> {
|
||||
control.set(XrealProtocol.SET_DIMMER, if (command.value == 1) 2 else 0)
|
||||
listener.onValue(command.message, command.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (System.nanoTime() - heartbeat > 5_000_000_000L) {
|
||||
listener.onValue(VitureProtocol.MSG_GET_DISPLAY, control.display())
|
||||
heartbeat = System.nanoTime()
|
||||
}
|
||||
}
|
||||
failure.get()?.let { throw it }
|
||||
} catch (e: Exception) {
|
||||
ready = false
|
||||
if (running && lastError != e.message) {
|
||||
lastError = e.message
|
||||
Log.w("XrealLink", "Connection unavailable: ${e.message}")
|
||||
listener.onUnavailable(e is XrealNetworkMissing)
|
||||
}
|
||||
} finally {
|
||||
ready = false
|
||||
tracking = false
|
||||
commands.clear()
|
||||
connection.compareAndSet(channels, null)
|
||||
channels?.close()
|
||||
try { reports?.join(2500) } catch (_: InterruptedException) { }
|
||||
}
|
||||
if (running) try { Thread.sleep(2000) } catch (_: InterruptedException) { }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
running = false
|
||||
ready = false
|
||||
tracking = false
|
||||
commands.clear()
|
||||
connection.getAndSet(null)?.close()
|
||||
connector.close() // Includes sockets still inside connect().
|
||||
worker.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept independent of Android so the real framing/acknowledgement path is testable. */
|
||||
internal class XrealControl(private val channels: XrealChannels, private val settleDelayMs: Long = 150) {
|
||||
private var transaction = 0
|
||||
private fun exchange(command: Int, value: Int? = null): ByteArray {
|
||||
transaction = if (transaction == Int.MAX_VALUE) 1 else transaction + 1
|
||||
channels.controlOutput.write(XrealProtocol.request(command, transaction, value))
|
||||
channels.controlOutput.flush()
|
||||
val deadline = System.nanoTime() + 3_000_000_000L
|
||||
repeat(32) { // Bound unsolicited button/status packets and stale responses.
|
||||
if (System.nanoTime() > deadline) throw IOException("XREAL control timed out")
|
||||
XrealProtocol.response(XrealProtocol.read(channels.controlInput), command, transaction)?.let { return it }
|
||||
}
|
||||
throw IOException("XREAL control response was not received")
|
||||
}
|
||||
fun display(): Int {
|
||||
val reply = exchange(XrealProtocol.GET_DISPLAY)
|
||||
return XrealProtocol.numeric(reply)?.takeIf { it in 2..5 || it == XrealModel.NATIVE_60HZ }
|
||||
?: throw IOException("Unsupported XREAL display response: ${reply.take(32).joinToString("") { "%02x".format(it) }}")
|
||||
}
|
||||
fun changeDisplay(mode: Int): Int {
|
||||
val stereo = mode == 5
|
||||
set(XrealProtocol.SET_INPUT, if (stereo) 1 else 0)
|
||||
// On 1S, SET_INPUT itself changes EDID. Sending SET_DISPLAY immediately
|
||||
// receives error 5001 while that change is pending. GET_DISPLAY returns
|
||||
// an empty property during the transition; wait for a real mode report.
|
||||
val current = awaitDisplay { (it == 5) == stereo }
|
||||
if (current == mode) return current
|
||||
set(XrealProtocol.SET_DISPLAY, mode)
|
||||
return awaitDisplay { it == mode }
|
||||
}
|
||||
private fun awaitDisplay(matches: (Int) -> Boolean): Int {
|
||||
val deadline = System.nanoTime() + 8_000_000_000L
|
||||
repeat(40) {
|
||||
val reply = exchange(XrealProtocol.GET_DISPLAY)
|
||||
val value = XrealProtocol.numeric(reply)
|
||||
if (value != null && matches(value)) return value
|
||||
if (value == null && !XrealProtocol.acknowledged(reply))
|
||||
throw IOException("Invalid XREAL display transition response")
|
||||
if (System.nanoTime() > deadline) throw IOException("XREAL display transition timed out")
|
||||
Thread.sleep(settleDelayMs)
|
||||
}
|
||||
throw IOException("XREAL display transition did not complete")
|
||||
}
|
||||
fun set(command: Int, value: Int) {
|
||||
val reply = exchange(command, value)
|
||||
if (!XrealProtocol.acknowledged(reply)) throw IOException("XREAL command %04x was not acknowledged: %s".format(
|
||||
command, reply.take(32).joinToString("") { "%02x".format(it) }))
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
/** First One-family target. Recovery and Air USB products must not use this transport. */
|
||||
object XrealModel : GlassesModel {
|
||||
const val VENDOR_ID = 0x3318
|
||||
const val PRODUCT_1S = 0x043e
|
||||
const val NATIVE_60HZ = 29 // Captured on 1S: 1920 x 1200 at 60 Hz.
|
||||
override val name = "XREAL 1S"
|
||||
override val controllable = true
|
||||
override val brightnessMax = 9
|
||||
override val volumeMax = 0
|
||||
override val hasSunblock = true
|
||||
override val diagonalFovDeg = 52f
|
||||
override val sbs90 = false
|
||||
|
||||
fun matches(vid: Int, pid: Int) = vid == VENDOR_ID && pid == PRODUCT_1S
|
||||
fun isDisplay(name: String) = name.contains("XREAL", ignoreCase = true)
|
||||
|
||||
// One-family EDID configuration values, not VITURE's display mode IDs.
|
||||
override fun mode(hz: Int, sbs: Boolean) = when {
|
||||
sbs -> 5
|
||||
hz >= 120 -> 4
|
||||
hz >= 90 -> 3
|
||||
else -> NATIVE_60HZ
|
||||
}
|
||||
override fun modeForStep(step: Int): Int? = null
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
|
||||
internal class XrealOrientationFilter {
|
||||
private var previous = 0L
|
||||
private var rotation = Quaternion.IDENTITY
|
||||
private var initialized = false
|
||||
private var bias = Vec3(0f, 0f, 0f)
|
||||
|
||||
fun update(sample: XrealProtocol.Sample): FloatArray? {
|
||||
if (sample.timeNs <= previous) return null
|
||||
val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f
|
||||
previous = sample.timeNs
|
||||
// 1S reports right/down/forward. Rotate 180 degrees about X into the
|
||||
// shared right/up/back basis before either gravity alignment or fusion.
|
||||
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
|
||||
val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z)
|
||||
val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)
|
||||
val norm = accel.length()
|
||||
val gravityValid = abs(norm - 9.81f) < 1.5f
|
||||
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
|
||||
if (!initialized) {
|
||||
if (!gravityValid) return null
|
||||
val up = accel.normalized()
|
||||
val axis = up.cross(Vec3(0f, 1f, 0f))
|
||||
rotation = if (up.y < -0.9999f) Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat())
|
||||
else Quaternion(1f + up.y, axis.x, axis.y, axis.z).normalized()
|
||||
initialized = true
|
||||
} else if (dt in 0.0001f..0.05f) {
|
||||
if (gravityValid && gyro.length() < 0.04f) {
|
||||
val alpha = (dt * 0.5f).coerceAtMost(1f)
|
||||
bias = bias * (1f - alpha) + gyro * alpha
|
||||
}
|
||||
var rate = gyro - bias
|
||||
if (gravityValid) {
|
||||
val predictedUp = rotation.conjugate().rotate(Vec3(0f, 1f, 0f))
|
||||
rate += accel.normalized().cross(predictedUp) * 1.5f
|
||||
}
|
||||
rotation = rotation.integrateBodyRate(rate, dt).normalized()
|
||||
}
|
||||
// Same right/up/back -> shared glasses basis used by the RayNeo adapter.
|
||||
return RayNeoOrientationFilter.glassesPose(floatArrayOf(rotation.w, rotation.x, rotation.y, rotation.z))
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/** Independent implementation of the One-family wire format; see xreal-investigation.md. */
|
||||
internal object XrealProtocol {
|
||||
const val HOST = "169.254.2.1"
|
||||
const val REPORT_PORT = 52998
|
||||
const val CONTROL_PORT = 52999
|
||||
const val REPORT = 0x2836
|
||||
const val GET_DISPLAY = 0x275e
|
||||
const val SET_DISPLAY = 0x275f
|
||||
const val SET_INPUT = 0x2822
|
||||
const val SET_BRIGHTNESS = 0x271c
|
||||
const val SET_DIMMER = 0x2727
|
||||
const val MAX_BODY = 64 * 1024
|
||||
|
||||
data class Frame(val command: Int, val body: ByteArray)
|
||||
data class Sample(val timeNs: Long, val gyro: Vec3, val accel: Vec3)
|
||||
|
||||
fun read(input: InputStream): Frame {
|
||||
val deadline = System.nanoTime() + 3_000_000_000L
|
||||
val header = ByteArray(6)
|
||||
input.readFully(header, deadline)
|
||||
val size = ByteBuffer.wrap(header, 2, 4).int
|
||||
if (size !in 0..MAX_BODY) throw IOException("Invalid XREAL frame length: $size")
|
||||
return Frame(((header[0].toInt() and 255) shl 8) or (header[1].toInt() and 255),
|
||||
ByteArray(size).also { input.readFully(it, deadline) })
|
||||
}
|
||||
|
||||
private fun InputStream.readFully(bytes: ByteArray, deadline: Long) {
|
||||
var at = 0
|
||||
while (at < bytes.size) {
|
||||
if (System.nanoTime() > deadline) throw IOException("XREAL frame timed out")
|
||||
val count = read(bytes, at, bytes.size - at)
|
||||
if (count < 0) throw EOFException("XREAL stream closed mid-frame")
|
||||
if (count == 0) throw IOException("XREAL stream made no progress")
|
||||
at += count
|
||||
}
|
||||
}
|
||||
|
||||
fun sample(frame: Frame): Sample? {
|
||||
if (frame.command != REPORT || frame.body.size != 128) return null
|
||||
val b = ByteBuffer.wrap(frame.body).order(ByteOrder.LITTLE_ENDIAN)
|
||||
if (b.getInt(24) != 11) return null // Magnetometer reports are a different type.
|
||||
val time = b.getLong(8)
|
||||
val gyro = Vec3(b.getFloat(28), b.getFloat(32), b.getFloat(36))
|
||||
val accel = Vec3(b.getFloat(40), b.getFloat(44), b.getFloat(48))
|
||||
if (time <= 0 || !gyro.valid(100f) || !accel.valid(200f)) return null
|
||||
return Sample(time, gyro, accel)
|
||||
}
|
||||
|
||||
private fun Vec3.valid(limit: Float) = listOf(x, y, z).all { it.isFinite() && kotlin.math.abs(it) <= limit }
|
||||
|
||||
fun request(command: Int, transaction: Int, value: Int? = null): ByteArray {
|
||||
require(transaction > 0)
|
||||
require(value == null || value in 0..127)
|
||||
val payload = if (value == null) byteArrayOf(0x18, 0) else byteArrayOf(0x1a, 2, 8, value.toByte())
|
||||
return ByteBuffer.allocate(10 + payload.size).putShort(command.toShort())
|
||||
.putInt(4 + payload.size).putInt(transaction or Int.MIN_VALUE).put(payload).array()
|
||||
}
|
||||
|
||||
/** No acknowledgement is inferred from a write; match both command and transaction. */
|
||||
fun response(frame: Frame, command: Int, transaction: Int): ByteArray? {
|
||||
if (frame.command != command || frame.body.size < 4 ||
|
||||
ByteBuffer.wrap(frame.body).int != transaction) return null
|
||||
return frame.body.copyOfRange(4, frame.body.size)
|
||||
}
|
||||
|
||||
fun acknowledged(payload: ByteArray) = payload.contentEquals(byteArrayOf(0x22, 0))
|
||||
fun numeric(payload: ByteArray): Int? = if (payload.size == 4 &&
|
||||
payload[0] == 0x22.toByte() && payload[1] == 2.toByte() && payload[2] == 0x10.toByte() &&
|
||||
payload[3].toInt() in 0..127) payload[3].toInt() else null
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
object AutoDriverTrial {
|
||||
private const val TAG = "AutoDriverTrial"
|
||||
private const val PREFS = "gpu_auto_trial"
|
||||
const val UNREADY_STARTS_BEFORE_ROLLBACK = 2
|
||||
|
||||
class Trial(
|
||||
val release: String, val name: String,
|
||||
val display: String, val linux: String,
|
||||
val previousDisplay: String, val previousLinux: String,
|
||||
val starts: Int,
|
||||
)
|
||||
|
||||
@Volatile private var app: Context? = null
|
||||
@Volatile private var watching: Trial? = null
|
||||
|
||||
fun release(pair: DriverPairs.DriverPair) = "${pair.key}@${pair.version}"
|
||||
|
||||
fun current(context: Context): Trial? {
|
||||
val p = prefs(context)
|
||||
val release = p.getString("release", null) ?: return null
|
||||
return Trial(
|
||||
release, p.getString("name", "").orEmpty(),
|
||||
p.getString("display", "").orEmpty(), p.getString("linux", "").orEmpty(),
|
||||
p.getString("previousDisplay", "").orEmpty(), p.getString("previousLinux", "").orEmpty(),
|
||||
p.getInt("starts", 0),
|
||||
)
|
||||
}
|
||||
|
||||
fun begin(context: Context, release: String, name: String, display: String, linux: String, previousDisplay: String, previousLinux: String) {
|
||||
prefs(context).edit()
|
||||
.putString("release", release).putString("name", name)
|
||||
.putString("display", display).putString("linux", linux)
|
||||
.putString("previousDisplay", previousDisplay).putString("previousLinux", previousLinux)
|
||||
.putInt("starts", 0)
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
val bad = bad(context)
|
||||
prefs(context).edit().clear().putStringSet("bad", bad).commit()
|
||||
}
|
||||
|
||||
fun bad(context: Context): Set<String> = prefs(context).getStringSet("bad", emptySet()).orEmpty()
|
||||
|
||||
fun sessionStarting(context: Context, display: String, linux: String) {
|
||||
val trial = current(context)
|
||||
watching = null
|
||||
if (trial == null || trial.display != display || trial.linux != linux) return
|
||||
app = context.applicationContext
|
||||
watching = trial
|
||||
prefs(context).edit().putInt("starts", trial.starts + 1).commit()
|
||||
}
|
||||
|
||||
fun sessionReady() {
|
||||
val trial = watching ?: return
|
||||
val context = app ?: return
|
||||
watching = null
|
||||
if (current(context)?.release != trial.release) return
|
||||
Log.i(TAG, "${trial.release} reached a ready session; keeping it")
|
||||
clear(context)
|
||||
prune(context, setOf(trial.display, trial.linux))
|
||||
}
|
||||
|
||||
fun rollbackDue(context: Context): Trial? = current(context)?.takeIf { it.starts >= UNREADY_STARTS_BEFORE_ROLLBACK }
|
||||
|
||||
fun rollBack(context: Context, trial: Trial) {
|
||||
Log.w(TAG, "${trial.release} never reached a ready session in ${trial.starts} starts; restoring the previous drivers")
|
||||
if (SessionPrefs.androidDriver(context) == trial.display) SessionPrefs.setAndroidDriver(context, trial.previousDisplay)
|
||||
if (SessionPrefs.linuxDriver(context) == trial.linux) SessionPrefs.setLinuxDriver(context, trial.previousLinux)
|
||||
val bad = bad(context) + trial.release
|
||||
prefs(context).edit().clear().putStringSet("bad", bad).commit()
|
||||
prune(context, setOf(trial.previousDisplay, trial.previousLinux, SessionPrefs.androidDriver(context), SessionPrefs.linuxDriver(context)))
|
||||
}
|
||||
|
||||
fun prune(context: Context, keep: Set<String>) {
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
val td = TurnipDriver(context)
|
||||
val owned = SessionPrefs.gpuAutoInstalled(context)
|
||||
for (id in owned - keep) {
|
||||
val bundle = DriverBundle.containing(context, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> {
|
||||
if (bundle.androidId in keep || bundle.linuxId in keep) continue
|
||||
DriverBundle.remove(context, bundle); TurnipReleases.forget(context, bundle.id)
|
||||
}
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(context, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(context, owned.intersect(keep))
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -12,15 +12,13 @@ data class Cardboard(
|
||||
val screenWidthDm: Int = 40,
|
||||
val screenDistanceDm: Int = 30,
|
||||
val headTracking: Boolean = true,
|
||||
val mono: Boolean = false,
|
||||
) {
|
||||
val lens: Viewer get() = Viewer.byId(viewer)
|
||||
|
||||
fun push(panelWidthMm: Float, panelHeightMm: Float, fovOverrideDeg: Float = 0f, displayHz: Float = 0f) {
|
||||
fun push(panelWidthMm: Float, panelHeightMm: Float) {
|
||||
val l = lens
|
||||
WaylandCompositor.nativeSetCardboard(enabled, source, ipdMm / 1000f, screenDistanceDm / 10f,
|
||||
screenWidthDm / 10f, l.k1, l.k2, if (fovOverrideDeg > 0f) fovOverrideDeg else fovDeg(l, panelHeightMm),
|
||||
if (mono) 0f else lensInset(panelWidthMm, l.lensSeparationMm), l.chroma, mono, displayHz)
|
||||
screenWidthDm / 10f, l.k1, l.k2, fovDeg(l, panelHeightMm), lensInset(panelWidthMm, l.lensSeparationMm), l.chroma)
|
||||
}
|
||||
|
||||
fun encode(): JSONObject = JSONObject()
|
||||
@@ -31,8 +29,7 @@ data class Cardboard(
|
||||
val lensSeparationMm: Float, val chroma: Float, val fallbackFovDeg: Float) {
|
||||
CARDBOARD_V2("cardboard2", 0.34f, 0.55f, 39.3f, 63.9f, 0.006f, 90f),
|
||||
CARDBOARD_V1("cardboard1", 0.441f, 0.156f, 42f, 60f, 0.004f, 80f),
|
||||
NONE("none", 0f, 0f, 0f, 0f, 0f, 60f),
|
||||
VITURE("viture", 0f, 0f, 0f, 0f, 0f, 23.3f);
|
||||
NONE("none", 0f, 0f, 0f, 0f, 0f, 60f);
|
||||
|
||||
companion object {
|
||||
fun byId(id: String): Viewer = entries.firstOrNull { it.id == id } ?: CARDBOARD_V2
|
||||
@@ -126,10 +123,6 @@ object HeadPose {
|
||||
a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c]
|
||||
}
|
||||
|
||||
private val GLASSES_AXES = floatArrayOf(0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f)
|
||||
|
||||
fun fromGlasses(x: Float, y: Float, z: Float, w: Float): FloatArray = multiply(fromQuaternion(x, y, z, w), GLASSES_AXES)
|
||||
|
||||
fun transpose(m: FloatArray): FloatArray = floatArrayOf(m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8])
|
||||
|
||||
fun apply(m: FloatArray, v: FloatArray): FloatArray = floatArrayOf(
|
||||
|
||||
@@ -81,7 +81,7 @@ data class GpuInfo(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
|
||||
|
||||
@@ -10,42 +10,19 @@ import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
class HeadTracker(context: Context, private val glasses: Boolean = false, private val rotation: () -> Int) : SensorEventListener {
|
||||
class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private val sensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
|
||||
?: sensors.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private var thread: HandlerThread? = null
|
||||
private var started = false
|
||||
private val glassesLock = Any()
|
||||
@Volatile private var reference: HeadPose.Reference? = null
|
||||
@Volatile private var recenterPending = true
|
||||
@Volatile private var latest: FloatArray? = null
|
||||
|
||||
private val glassesSink = com.droiddeck.launcher.glasses.Glasses.PoseSink { x, y, z, w, _ ->
|
||||
val m = HeadPose.fromGlasses(x, y, z, w)
|
||||
synchronized(glassesLock) {
|
||||
if (!started) return@PoseSink
|
||||
latest = m
|
||||
apply(m)
|
||||
}
|
||||
}
|
||||
|
||||
val usesGlasses: Boolean get() = glasses
|
||||
|
||||
val available: Boolean get() = sensor != null
|
||||
val sensorName: String get() = sensor?.let { "${it.name} (${if (it.type == Sensor.TYPE_GAME_ROTATION_VECTOR) "game rotation vector" else "rotation vector"})" } ?: "none"
|
||||
|
||||
fun start() {
|
||||
if (glasses) {
|
||||
synchronized(glassesLock) {
|
||||
if (started) return
|
||||
started = true
|
||||
recenterPending = true
|
||||
}
|
||||
com.droiddeck.launcher.glasses.Glasses.setPoseSink(glassesSink)
|
||||
WaylandCompositor.nativeLog("cardboard", "head tracking on: VITURE glasses IMU")
|
||||
return
|
||||
}
|
||||
val s = sensor ?: run {
|
||||
WaylandCompositor.nativeLog("cardboard", "no rotation sensor: the virtual screen stays head-locked")
|
||||
WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false)
|
||||
@@ -60,15 +37,6 @@ class HeadTracker(context: Context, private val glasses: Boolean = false, privat
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (glasses) {
|
||||
synchronized(glassesLock) {
|
||||
if (!started) return
|
||||
started = false
|
||||
WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false)
|
||||
}
|
||||
com.droiddeck.launcher.glasses.Glasses.clearPoseSink(glassesSink)
|
||||
return
|
||||
}
|
||||
val t = thread ?: return
|
||||
sensors.unregisterListener(this)
|
||||
t.quitSafely()
|
||||
@@ -78,8 +46,7 @@ class HeadTracker(context: Context, private val glasses: Boolean = false, privat
|
||||
|
||||
fun recenter() {
|
||||
recenterPending = true
|
||||
if (glasses) synchronized(glassesLock) { if (started) latest?.let { apply(it) } }
|
||||
else latest?.let { apply(it) }
|
||||
latest?.let { apply(it) }
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
|
||||
@@ -5,8 +5,6 @@ import com.droiddeck.launcher.agent.AgentGuest
|
||||
import org.json.JSONObject
|
||||
|
||||
object SteamVr {
|
||||
const val APP_ID = "250820"
|
||||
|
||||
enum class Status {
|
||||
UNAVAILABLE,
|
||||
NOT_INSTALLED,
|
||||
@@ -15,23 +13,6 @@ object SteamVr {
|
||||
RUNNING,
|
||||
}
|
||||
|
||||
internal fun shouldPoll(status: Status?, cardboardEnabled: Boolean, pictureSwitched: Boolean,
|
||||
glassesControl: Boolean, requested: Boolean): Boolean =
|
||||
requested || cardboardEnabled || pictureSwitched || glassesControl ||
|
||||
status == Status.RUNNING || status == Status.INSTALLING
|
||||
|
||||
internal fun autoAction(
|
||||
status: Status, requested: Boolean, loading: Boolean, steamSession: Boolean,
|
||||
startTried: Boolean, installTried: Boolean,
|
||||
): String? {
|
||||
if (!requested || loading || !steamSession || startTried) return null
|
||||
return when (status) {
|
||||
Status.READY -> "start"
|
||||
Status.NOT_INSTALLED -> if (installTried) null else "install"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun status(context: Context): Status = runCatching {
|
||||
parse(AgentGuest.call(context, JSONObject().put("kind", "vr").put("action", "status"), STATUS_TIMEOUT_MS))
|
||||
}.getOrDefault(Status.UNAVAILABLE)
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
|
||||
/** Only new compositor output is evidence of a fault in the current session. */
|
||||
internal class SteamVrDeviceLossLog(private val file: File) {
|
||||
private var identity: Any? = identity()
|
||||
private var position = file.length()
|
||||
private var prefix = runCatching {
|
||||
RandomAccessFile(file, "r").use { input -> ByteArray(minOf(64, input.length()).toInt()).also(input::readFully) }
|
||||
}.getOrDefault(byteArrayOf())
|
||||
private var partial = ""
|
||||
private var handled = false
|
||||
|
||||
private fun identity(): Any? = runCatching {
|
||||
Files.readAttributes(file.toPath(), BasicFileAttributes::class.java).fileKey()
|
||||
}.getOrNull()
|
||||
|
||||
fun poll(): Boolean {
|
||||
if (handled) return true
|
||||
if (!file.isFile) return false
|
||||
return runCatching {
|
||||
val current = identity()
|
||||
RandomAccessFile(file, "r").use { input ->
|
||||
val head = ByteArray(minOf(64, input.length()).toInt()).also(input::readFully)
|
||||
val replaced = prefix.isNotEmpty() && !head.take(prefix.size).toByteArray().contentEquals(prefix)
|
||||
if (current != identity || input.length() < position || replaced) {
|
||||
position = 0
|
||||
partial = ""
|
||||
}
|
||||
identity = current
|
||||
prefix = head
|
||||
// Fault loops can produce megabytes per second. Bound work and keep the newest output.
|
||||
if (input.length() - position > MAX_READ) {
|
||||
position = input.length() - MAX_READ
|
||||
partial = ""
|
||||
}
|
||||
input.seek(position)
|
||||
val bytes = ByteArray(minOf(MAX_READ, input.length() - position).toInt())
|
||||
input.readFully(bytes)
|
||||
position = input.filePointer
|
||||
val text = partial + bytes.toString(Charsets.UTF_8)
|
||||
val end = text.lastIndexOf('\n')
|
||||
partial = text.substring(end + 1).takeLast(2048)
|
||||
if (end >= 0 && isDeviceLoss(text.substring(0, end + 1))) handled = true
|
||||
handled
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_READ = 128 * 1024L
|
||||
internal fun isDeviceLoss(text: String): Boolean =
|
||||
Regex("\\bVK_ERROR_DEVICE_LOST\\b|\\bvkerror\\s*=\\s*-4\\b", RegexOption.IGNORE_CASE).containsMatchIn(text)
|
||||
}
|
||||
}
|
||||
|
||||
internal object SteamVrRecoveryPolicy {
|
||||
const val COOLDOWN_MS = 10 * 60 * 1000L
|
||||
const val STOP_MS = 15_000L
|
||||
const val LOGS_MS = 30_000L
|
||||
private const val POLL_MS = 250L
|
||||
|
||||
enum class Shutdown { COMPLETE, LOGS_PENDING, STUCK }
|
||||
|
||||
fun canRestartAutomatically(lastAttempt: Long, now: Long): Boolean =
|
||||
lastAttempt == 0L || (now >= lastAttempt && now - lastAttempt >= COOLDOWN_MS)
|
||||
|
||||
fun awaitShutdown(session: () -> JSONObject?, now: () -> Long, sleep: (Long) -> Unit): Shutdown {
|
||||
fun await(ms: Long, done: (JSONObject) -> Boolean): Boolean {
|
||||
val deadline = now() + ms
|
||||
while (true) {
|
||||
if (session()?.let(done) == true) return true
|
||||
if (now() >= deadline) return false
|
||||
sleep(POLL_MS)
|
||||
}
|
||||
}
|
||||
if (!await(STOP_MS) { it.optString("phase") in setOf("IDLE", "FAILED") }) return Shutdown.STUCK
|
||||
return if (await(LOGS_MS) { !it.optBoolean("artifactsAvailable") || it.optBoolean("artifactsComplete") }) Shutdown.COMPLETE
|
||||
else Shutdown.LOGS_PENDING
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import java.io.File
|
||||
|
||||
/** Process-wide cursor survives Activity recreation; a new session ignores older log errors. */
|
||||
internal object SteamVrHealth {
|
||||
private var session: String? = null
|
||||
private var log: SteamVrDeviceLossLog? = null
|
||||
|
||||
@Synchronized fun begin(context: Context, sessionId: String?) {
|
||||
if (sessionId == session && log != null) return
|
||||
session = sessionId
|
||||
log = SteamVrDeviceLossLog(File(LinuxRuntime.rootDir(context),
|
||||
"root/.local/share/Steam/logs/vrcompositor.txt"))
|
||||
}
|
||||
|
||||
@Synchronized fun deviceLost(): Boolean = log?.poll() == true
|
||||
}
|
||||
|
||||
internal class SteamVrRecovery(private val activity: Activity, private val foreground: () -> Boolean) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var dialog: AlertDialog? = null
|
||||
private var handled = false
|
||||
private var countdown: Runnable? = null
|
||||
|
||||
fun detected() {
|
||||
if (handled || !foreground() || activity.isFinishing || activity.isDestroyed) return
|
||||
handled = true
|
||||
SessionEvents.record("steamvr.device_lost")
|
||||
val prefs = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val automatic = SteamVrRecoveryPolicy.canRestartAutomatically(prefs.getLong(LAST_ATTEMPT, 0), System.currentTimeMillis())
|
||||
var seconds = 8
|
||||
fun message() = if (automatic) activity.getString(R.string.steamvr_recovery_countdown, seconds)
|
||||
else activity.getString(R.string.steamvr_recovery_repeated)
|
||||
val prompt = AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.steamvr_recovery_title)
|
||||
.setMessage(message())
|
||||
.setPositiveButton(R.string.steamvr_recovery_restart) { _, _ -> restart() }
|
||||
.setNegativeButton(R.string.steamvr_recovery_cancel) { _, _ -> }
|
||||
.create()
|
||||
prompt.setOnDismissListener { countdown?.let(handler::removeCallbacks); countdown = null; dialog = null }
|
||||
dialog = prompt
|
||||
// A touch meant for the game must not silently dismiss the recovery controls.
|
||||
prompt.setCanceledOnTouchOutside(false)
|
||||
prompt.show()
|
||||
if (automatic) {
|
||||
countdown = object : Runnable {
|
||||
override fun run() {
|
||||
if (!foreground() || activity.isFinishing || activity.isDestroyed) { handled = false; prompt.dismiss(); return }
|
||||
if (--seconds <= 0) { prompt.dismiss(); restart(); return }
|
||||
prompt.setMessage(message())
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}.also { handler.postDelayed(it, 1000) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun restart() {
|
||||
if (!foreground()) return
|
||||
// Commit before crossing processes so a repeated fault cannot create a restart loop.
|
||||
if (!activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putLong(LAST_ATTEMPT, System.currentTimeMillis()).commit()) return
|
||||
SessionEvents.record("steamvr.recovery_requested")
|
||||
activity.startActivity(Intent(activity, SteamVrRestartActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
fun close() { countdown?.let(handler::removeCallbacks); countdown = null; dialog?.dismiss() }
|
||||
|
||||
companion object {
|
||||
internal const val PREFS = "steamvr_recovery"
|
||||
internal const val LAST_ATTEMPT = "lastAttempt"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.util.AtomicFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
internal object SteamVrRecoverySettings {
|
||||
/** After a graphics crash SteamVR can quarantine our HMD, leaving a server with no VR output. */
|
||||
fun unblockHeadset(file: File): Boolean {
|
||||
if (!file.isFile) return false
|
||||
val settings = JSONObject(file.readText())
|
||||
val driver = settings.optJSONObject("driver_droiddeck") ?: return false
|
||||
if (!driver.optBoolean("blocked_by_safe_mode")) return false
|
||||
// Preserve every other setting, including safe mode and blocks on other drivers.
|
||||
driver.put("blocked_by_safe_mode", false)
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
try {
|
||||
output.write(settings.toString(3).toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (e: Exception) {
|
||||
atomic.failWrite(output)
|
||||
throw e
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import android.view.Gravity
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.SessionActivity
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import org.json.JSONObject
|
||||
|
||||
/** A visible, private process survives destruction of the app's native graphics context. */
|
||||
class SteamVrRestartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(TextView(this).apply {
|
||||
setText(R.string.steamvr_recovery_restarting)
|
||||
gravity = Gravity.CENTER
|
||||
textSize = 22f
|
||||
})
|
||||
Thread({ recover() }, "steamvr-process-recovery").start()
|
||||
}
|
||||
|
||||
private fun recover() {
|
||||
try {
|
||||
val manager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
|
||||
val main = manager.runningAppProcesses?.firstOrNull {
|
||||
it.processName == packageName && it.uid == Process.myUid() && it.pid != Process.myPid()
|
||||
} ?: error("DroidDeck process is unavailable")
|
||||
val uri = Uri.parse("content://$packageName.agent")
|
||||
contentResolver.call(uri, "stop", null, null)
|
||||
val shutdown = SteamVrRecoveryPolicy.awaitShutdown({
|
||||
runCatching {
|
||||
contentResolver.call(uri, "state", null, null)?.getString("json")?.let(::JSONObject)?.optJSONObject("session")
|
||||
}.getOrNull()
|
||||
}, SystemClock::elapsedRealtime, SystemClock::sleep)
|
||||
if (shutdown != SteamVrRecoveryPolicy.Shutdown.COMPLETE)
|
||||
android.util.Log.w("SteamVrRecovery", "Restarting before the session finished stopping: $shutdown")
|
||||
// Only the verified main process, never a caller-supplied PID or an external app.
|
||||
Process.killProcess(main.pid)
|
||||
val exited = SystemClock.elapsedRealtime() + 5000
|
||||
while (java.io.File("/proc/${main.pid}").exists() && SystemClock.elapsedRealtime() < exited) SystemClock.sleep(50)
|
||||
check(!java.io.File("/proc/${main.pid}").exists()) { "DroidDeck process did not exit" }
|
||||
val settings = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(this),
|
||||
"root/.local/share/Steam/config/steamvr.vrsettings")
|
||||
runCatching { SteamVrRecoverySettings.unblockHeadset(settings) }
|
||||
.onSuccess { if (it) android.util.Log.i("SteamVrRecovery", "Cleared the DroidDeck HMD crash block before restarting SteamVR") }
|
||||
.onFailure { android.util.Log.w("SteamVrRecovery", "Could not read the SteamVR settings; restarting with them unchanged", it) }
|
||||
runOnUiThread {
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK))
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("SteamVrRecovery", "Could not restart DroidDeck", e)
|
||||
runOnUiThread {
|
||||
setContentView(TextView(this).apply {
|
||||
setText(R.string.steamvr_recovery_failed)
|
||||
gravity = Gravity.CENTER
|
||||
textSize = 22f
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object TpUbwcHint {
|
||||
const val ENV = "FD_DEV_FEATURES"
|
||||
private const val TAG = "TpUbwcHint"
|
||||
|
||||
@Volatile private var resolved = false
|
||||
@Volatile private var cached: String? = null
|
||||
|
||||
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
|
||||
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
|
||||
internal fun features(model: Int, systemDriverVersion: String?): String? {
|
||||
if (model != 740) return null
|
||||
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
|
||||
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun forThisDevice(): String? {
|
||||
if (resolved) return cached
|
||||
val model = GpuInfo.detect().model
|
||||
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
|
||||
cached = features(model, version)
|
||||
resolved = true
|
||||
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
return cached
|
||||
}
|
||||
|
||||
fun applyToProcess() {
|
||||
val value = forThisDevice() ?: return
|
||||
runCatching { android.system.Os.setenv(ENV, value, true) }
|
||||
.onFailure { Log.w(TAG, "setenv $ENV failed: ${it.javaClass.simpleName}") }
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import android.util.Log;
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
import com.droiddeck.launcher.core.TarZst;
|
||||
import com.droiddeck.launcher.session.QuestExperimental;
|
||||
import com.droiddeck.launcher.session.SessionPrefs;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -282,8 +281,6 @@ public final class TurnipDriver {
|
||||
if (forced.startsWith("a7")) return DRIVER_A7XX;
|
||||
if (forced.startsWith("a6")) return DRIVER_A6XX;
|
||||
}
|
||||
String quest = QuestExperimental.INSTANCE.defaultAndroidDriver();
|
||||
if (quest != null) return quest;
|
||||
String model = gpuModel();
|
||||
Log.i(TAG, "gpu model: " + (model == null ? "unknown" : model));
|
||||
if (model != null) {
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object TurnipReleases {
|
||||
private const val TAG = "TurnipReleases"
|
||||
private const val PREFS = "turnip_releases"
|
||||
private const val KEY_RELEASE = "latest"
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
@@ -107,24 +104,7 @@ object TurnipReleases {
|
||||
}
|
||||
|
||||
private fun get(context: Context, url: String): String {
|
||||
val sources = DownloadSource.candidates(url)
|
||||
var last: IOException? = null
|
||||
for ((index, source) in sources.withIndex()) {
|
||||
try {
|
||||
val body = get(context, source, url)
|
||||
DownloadSource.succeeded(source)
|
||||
return body
|
||||
} catch (e: IOException) {
|
||||
last = e
|
||||
if (index < sources.lastIndex) Log.i(TAG, "retrying $url via $source")
|
||||
}
|
||||
}
|
||||
DownloadSource.failed()
|
||||
throw last ?: IOException(context.getString(R.string.turnip_github_http, 0))
|
||||
}
|
||||
|
||||
private fun get(context: Context, source: String, url: String): String {
|
||||
val c = URL(source).openConnection() as HttpURLConnection
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
c.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
@@ -180,13 +160,39 @@ object TurnipReleases {
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val ok = Downloader.downloadFile(asset.url, target, true, asset.sha256) { f -> progress(if (f < 0) -1 else (f * 100).toInt()) }
|
||||
if (!ok) throw IOException(context.getString(R.string.turnip_download_failed))
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 60_000
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
c.instanceFollowRedirects = true
|
||||
try {
|
||||
if (c.responseCode != 200) throw IOException(context.getString(R.string.turnip_download_http, c.responseCode))
|
||||
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
|
||||
c.inputStream.use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var done = 0L
|
||||
var last = -1
|
||||
while (true) {
|
||||
val r = input.read(buf)
|
||||
if (r <= 0) break
|
||||
out.write(buf, 0, r)
|
||||
done += r
|
||||
val pct = if (total > 0) (done * 100 / total).toInt() else 0
|
||||
if (pct != last) { last = pct; progress(pct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(target)
|
||||
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
|
||||
throw e
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun downloads(context: Context): JSONObject =
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
|
||||
internal class Landmark(val x: Float, val y: Float, val visibility: Float)
|
||||
|
||||
internal class WorldPoint(val x: Float, val y: Float, val z: Float)
|
||||
|
||||
internal class Intrinsics(val fx: Float, val fy: Float, val cx: Float, val cy: Float, val width: Int, val height: Int)
|
||||
|
||||
internal class CameraLevel(up: Vec3) {
|
||||
val right: Vec3
|
||||
val up: Vec3
|
||||
val back: Vec3
|
||||
val valid: Boolean
|
||||
|
||||
init {
|
||||
val u = up.normalized()
|
||||
val forward = Vec3(0f, 0f, 1f)
|
||||
val along = u.x * forward.x + u.y * forward.y + u.z * forward.z
|
||||
val horizontal = Vec3(forward.x - u.x * along, forward.y - u.y * along, forward.z - u.z * along)
|
||||
valid = u.length() > 0.5f && horizontal.length() > MIN_HORIZONTAL
|
||||
this.up = u
|
||||
back = (horizontal * -1f).normalized()
|
||||
right = this.up.cross(back)
|
||||
}
|
||||
|
||||
fun level(v: Vec3) = Vec3(dot(v, right), dot(v, up), dot(v, back))
|
||||
|
||||
private fun dot(a: Vec3, b: Vec3) = a.x * b.x + a.y * b.y + a.z * b.z
|
||||
|
||||
companion object {
|
||||
private const val MIN_HORIZONTAL = 0.25f
|
||||
}
|
||||
}
|
||||
|
||||
internal class BodyFrame(
|
||||
val hands: Array<Vec3?>,
|
||||
val faceYaw: Float?,
|
||||
val distance: Float,
|
||||
val shoulderSpan: Float,
|
||||
val seen: Boolean,
|
||||
val estimated: BooleanArray = BooleanArray(2),
|
||||
val edgeRoom: Float = 1f,
|
||||
val joints: Array<FloatArray?> = arrayOfNulls(2),
|
||||
)
|
||||
|
||||
internal class HandFix(val palm: Vec3, val x: Float, val y: Float, val extent: Float, val points: FloatArray, val world: List<WorldPoint> = emptyList())
|
||||
|
||||
internal object Pnp {
|
||||
fun translation(count: Int, k: Intrinsics, x: (Int) -> Float, y: (Int) -> Float, world: (Int) -> WorldPoint): Vec3? {
|
||||
val ata = Array(3) { DoubleArray(3) }
|
||||
val atb = DoubleArray(3)
|
||||
for (i in 0 until count) {
|
||||
val a = ((x(i) - k.cx) / k.fx).toDouble()
|
||||
val b = ((y(i) - k.cy) / k.fy).toDouble()
|
||||
val w = world(i)
|
||||
add(ata, atb, doubleArrayOf(1.0, 0.0, -a), a * w.z - w.x)
|
||||
add(ata, atb, doubleArrayOf(0.0, 1.0, -b), b * w.z - w.y)
|
||||
}
|
||||
val t = solve3(ata, atb) ?: return null
|
||||
return Vec3(t[0].toFloat(), t[1].toFloat(), t[2].toFloat())
|
||||
}
|
||||
|
||||
private fun add(ata: Array<DoubleArray>, atb: DoubleArray, row: DoubleArray, rhs: Double) {
|
||||
for (i in 0..2) {
|
||||
atb[i] += row[i] * rhs
|
||||
for (j in 0..2) ata[i][j] += row[i] * row[j]
|
||||
}
|
||||
}
|
||||
|
||||
private fun solve3(a: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
|
||||
val det = det3(a)
|
||||
if (abs(det) < 1e-12) return null
|
||||
return DoubleArray(3) { col -> det3(Array(3) { r -> DoubleArray(3) { c -> if (c == col) b[r] else a[r][c] } }) / det }
|
||||
}
|
||||
|
||||
private fun det3(m: Array<DoubleArray>) = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
|
||||
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
internal object BodyPose {
|
||||
const val NOSE = 0
|
||||
const val LEFT_EYE = 2
|
||||
const val RIGHT_EYE = 5
|
||||
const val LEFT_EAR = 7
|
||||
const val RIGHT_EAR = 8
|
||||
const val RIGHT_MOUTH = 10
|
||||
const val LEFT_SHOULDER = 11
|
||||
const val RIGHT_SHOULDER = 12
|
||||
const val LEFT_ELBOW = 13
|
||||
const val RIGHT_ELBOW = 14
|
||||
const val LEFT_WRIST = 15
|
||||
const val RIGHT_WRIST = 16
|
||||
const val LEFT_PINKY = 17
|
||||
const val RIGHT_PINKY = 18
|
||||
const val LEFT_INDEX = 19
|
||||
const val RIGHT_INDEX = 20
|
||||
const val LEFT_HIP = 23
|
||||
const val RIGHT_HIP = 24
|
||||
|
||||
private val ANCHORS = intArrayOf(NOSE, LEFT_EYE, RIGHT_EYE, LEFT_EAR, RIGHT_EAR, LEFT_SHOULDER, RIGHT_SHOULDER, LEFT_HIP, RIGHT_HIP)
|
||||
private val WRISTS = intArrayOf(LEFT_WRIST, RIGHT_WRIST)
|
||||
private val PINKIES = intArrayOf(LEFT_PINKY, RIGHT_PINKY)
|
||||
private val INDEXES = intArrayOf(LEFT_INDEX, RIGHT_INDEX)
|
||||
const val MIN_VISIBILITY = 0.6f
|
||||
|
||||
fun solve(
|
||||
image: List<Landmark>,
|
||||
world: List<WorldPoint>,
|
||||
k: Intrinsics,
|
||||
level: CameraLevel,
|
||||
fixes: Array<HandFix?>? = null,
|
||||
held: FloatArray? = null,
|
||||
): BodyFrame? {
|
||||
if (image.size <= RIGHT_HIP || world.size <= RIGHT_HIP || !level.valid) return null
|
||||
val t = translation(image, world, k) ?: return null
|
||||
fun at(i: Int): Vec3 {
|
||||
val w = world[i]
|
||||
val depth = w.z + t.z
|
||||
val px = image[i].x * k.width
|
||||
val py = image[i].y * k.height
|
||||
return Vec3((px - k.cx) / k.fx * depth, (py - k.cy) / k.fy * depth, depth)
|
||||
}
|
||||
fun visible(i: Int) = image[i].visibility >= MIN_VISIBILITY && image[i].x in 0f..1f && image[i].y in 0f..1f
|
||||
val eyesSeen = visible(LEFT_EYE) && visible(RIGHT_EYE)
|
||||
val shouldersSeen = visible(LEFT_SHOULDER) && visible(RIGHT_SHOULDER)
|
||||
val hands = arrayOfNulls<Vec3>(2)
|
||||
val estimated = BooleanArray(2)
|
||||
val joints = arrayOfNulls<FloatArray>(2)
|
||||
if (fixes == null) {
|
||||
val head = if (eyesSeen) (at(LEFT_EYE) + at(RIGHT_EYE)) * 0.5f else null
|
||||
if (head != null) for (side in 0..1) {
|
||||
if (!visible(WRISTS[side])) continue
|
||||
val grip = (at(WRISTS[side]) + at(PINKIES[side]) + at(INDEXES[side])) * (1f / 3f)
|
||||
hands[side] = level.level(grip - head)
|
||||
}
|
||||
} else {
|
||||
val head = if (eyesSeen) eyes(image, world, k) else null
|
||||
if (head != null) for (side in 0..1) {
|
||||
val fix = fixes.getOrNull(side)
|
||||
if (fix != null) {
|
||||
hands[side] = level.level(fix.palm - head)
|
||||
joints[side] = handJoints(fix.world, level)
|
||||
continue
|
||||
}
|
||||
val depth = held?.getOrNull(side)?.takeIf { it.isFinite() && it > 0f } ?: continue
|
||||
if (!visible(WRISTS[side])) continue
|
||||
val gx = (image[WRISTS[side]].x + image[PINKIES[side]].x + image[INDEXES[side]].x) / 3f * k.width
|
||||
val gy = (image[WRISTS[side]].y + image[PINKIES[side]].y + image[INDEXES[side]].y) / 3f * k.height
|
||||
hands[side] = level.level(Vec3((gx - k.cx) / k.fx * depth, (gy - k.cy) / k.fy * depth, depth) - head)
|
||||
estimated[side] = true
|
||||
}
|
||||
}
|
||||
val faceYaw = if (visible(NOSE) && visible(LEFT_EAR) && visible(RIGHT_EAR)) {
|
||||
val n = world[NOSE]
|
||||
val l = world[LEFT_EAR]
|
||||
val r = world[RIGHT_EAR]
|
||||
val f = level.level(Vec3(n.x - (l.x + r.x) / 2, n.y - (l.y + r.y) / 2, n.z - (l.z + r.z) / 2))
|
||||
if (hypot(f.x, f.z) > 0.02f) atan2(-f.x, -f.z) else null
|
||||
} else null
|
||||
val span = if (shouldersSeen) hypot(image[LEFT_SHOULDER].x - image[RIGHT_SHOULDER].x,
|
||||
(image[LEFT_SHOULDER].y - image[RIGHT_SHOULDER].y) * k.height / k.width) else 0f
|
||||
var room = 1f
|
||||
for (i in EDGE_JOINTS) {
|
||||
if (image[i].visibility < MIN_VISIBILITY) continue
|
||||
room = minOf(room, image[i].x, 1f - image[i].x, image[i].y, 1f - image[i].y)
|
||||
}
|
||||
return BodyFrame(hands, faceYaw, t.z, span, eyesSeen && shouldersSeen, estimated, room, joints)
|
||||
}
|
||||
|
||||
fun handJoints(world: List<WorldPoint>, level: CameraLevel): FloatArray? {
|
||||
if (world.size < HAND_POINTS) return null
|
||||
var mx = 0f
|
||||
var my = 0f
|
||||
var mz = 0f
|
||||
for (i in PALM) {
|
||||
mx += world[i].x
|
||||
my += world[i].y
|
||||
mz += world[i].z
|
||||
}
|
||||
val n = PALM.size.toFloat()
|
||||
val out = FloatArray(HAND_POINTS * 3)
|
||||
for (i in 0 until HAND_POINTS) {
|
||||
val v = level.level(Vec3(world[i].x - mx / n, world[i].y - my / n, world[i].z - mz / n))
|
||||
out[i * 3] = v.x
|
||||
out[i * 3 + 1] = v.y
|
||||
out[i * 3 + 2] = v.z
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun eyes(image: List<Landmark>, world: List<WorldPoint>, k: Intrinsics): Vec3? {
|
||||
val used = HEAD.filter { image[it].visibility >= MIN_VISIBILITY }
|
||||
if (used.size < MIN_HEAD_POINTS) return null
|
||||
val t = Pnp.translation(used.size, k, { image[used[it]].x * k.width }, { image[used[it]].y * k.height }, { world[used[it]] })
|
||||
?: return null
|
||||
if (!(t.z > MIN_DISTANCE && t.z < MAX_DISTANCE)) return null
|
||||
val l = world[LEFT_EYE]
|
||||
val r = world[RIGHT_EYE]
|
||||
return Vec3(t.x + (l.x + r.x) / 2, t.y + (l.y + r.y) / 2, t.z + (l.z + r.z) / 2)
|
||||
}
|
||||
|
||||
fun handFix(points: FloatArray, world: List<WorldPoint>, k: Intrinsics): HandFix? {
|
||||
if (world.size < HAND_POINTS || points.size < HAND_POINTS * 2) return null
|
||||
val t = Pnp.translation(HAND_POINTS, k, { points[it * 2] }, { points[it * 2 + 1] }, { world[it] }) ?: return null
|
||||
if (!(t.z > MIN_HAND_DISTANCE && t.z < MAX_DISTANCE)) return null
|
||||
var px = 0f
|
||||
var py = 0f
|
||||
var wx = 0f
|
||||
var wy = 0f
|
||||
var wz = 0f
|
||||
for (i in PALM) {
|
||||
px += points[i * 2]
|
||||
py += points[i * 2 + 1]
|
||||
wx += world[i].x
|
||||
wy += world[i].y
|
||||
wz += world[i].z
|
||||
}
|
||||
val n = PALM.size.toFloat()
|
||||
var minX = Float.MAX_VALUE
|
||||
var maxX = -Float.MAX_VALUE
|
||||
var minY = Float.MAX_VALUE
|
||||
var maxY = -Float.MAX_VALUE
|
||||
for (i in 0 until HAND_POINTS) {
|
||||
minX = minOf(minX, points[i * 2])
|
||||
maxX = maxOf(maxX, points[i * 2])
|
||||
minY = minOf(minY, points[i * 2 + 1])
|
||||
maxY = maxOf(maxY, points[i * 2 + 1])
|
||||
}
|
||||
return HandFix(Vec3(t.x + wx / n, t.y + wy / n, t.z + wz / n), px / n, py / n, maxOf(maxX - minX, maxY - minY), points, world)
|
||||
}
|
||||
|
||||
private fun translation(image: List<Landmark>, world: List<WorldPoint>, k: Intrinsics): Vec3? {
|
||||
val used = ANCHORS.filter { image[it].visibility >= MIN_VISIBILITY }
|
||||
if (used.size < MIN_ANCHORS) return null
|
||||
val t = Pnp.translation(used.size, k, { image[used[it]].x * k.width }, { image[used[it]].y * k.height }, { world[used[it]] })
|
||||
?: return null
|
||||
if (!(t.z > MIN_DISTANCE && t.z < MAX_DISTANCE)) return null
|
||||
return t
|
||||
}
|
||||
|
||||
const val HAND_POINTS = 21
|
||||
private val HEAD = (NOSE..RIGHT_MOUTH).toList()
|
||||
private val PALM = intArrayOf(0, 5, 9, 13, 17)
|
||||
private val EDGE_JOINTS = intArrayOf(LEFT_ELBOW, RIGHT_ELBOW, LEFT_WRIST, RIGHT_WRIST)
|
||||
private const val MIN_ANCHORS = 4
|
||||
private const val MIN_HEAD_POINTS = 4
|
||||
private const val MIN_DISTANCE = 0.2f
|
||||
private const val MIN_HAND_DISTANCE = 0.05f
|
||||
private const val MAX_DISTANCE = 6f
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.util.Range
|
||||
import android.util.Size
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.camera2.interop.Camera2Interop
|
||||
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import com.google.mediapipe.framework.image.BitmapImageBuilder
|
||||
import com.google.mediapipe.tasks.core.BaseOptions
|
||||
import com.google.mediapipe.tasks.core.Delegate
|
||||
import com.google.mediapipe.tasks.vision.core.RunningMode
|
||||
import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.abs
|
||||
|
||||
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
|
||||
internal class HandCamera(
|
||||
context: Context,
|
||||
private val output: File?,
|
||||
private val preview: Boolean,
|
||||
) : LifecycleOwner {
|
||||
class View(
|
||||
val image: Bitmap,
|
||||
val front: Boolean,
|
||||
val landmarks: List<Landmark>?,
|
||||
val placement: Placement,
|
||||
val advice: Advice,
|
||||
)
|
||||
|
||||
private val context = context.applicationContext
|
||||
private val registry = LifecycleRegistry(this)
|
||||
override val lifecycle: Lifecycle get() = registry
|
||||
private val executor = Executors.newSingleThreadExecutor { Thread(it, "hand-camera") }
|
||||
private val sensors = this.context.getSystemService(SensorManager::class.java)
|
||||
private val power = this.context.getSystemService(PowerManager::class.java)
|
||||
private val state = MutableStateFlow<View?>(null)
|
||||
val view: StateFlow<View?> = state.asStateFlow()
|
||||
private val broken = MutableStateFlow(false)
|
||||
val failed: StateFlow<Boolean> = broken.asStateFlow()
|
||||
@Volatile private var up: Vec3? = null
|
||||
@Volatile private var thermal = 0
|
||||
@Volatile private var stopped = false
|
||||
@Volatile var bareHands = false
|
||||
set(value) {
|
||||
if (field != value) Log.i(TAG, if (value) "no controllers: tracking bare hands" else "controllers in use: placing them only")
|
||||
field = value
|
||||
}
|
||||
private var provider: ProcessCameraProvider? = null
|
||||
private var analysis: ImageAnalysis? = null
|
||||
private var landmarker: PoseLandmarker? = null
|
||||
private var hands: HandModel? = null
|
||||
private val heldDepth = FloatArray(2) { Float.NaN }
|
||||
private val heldAt = LongArray(2)
|
||||
private var shm: HandCameraShm? = null
|
||||
private var camera: TrackingCamera? = null
|
||||
private var zoom = 1f
|
||||
private var lastRun = 0L
|
||||
private var lastStampMs = 0L
|
||||
private var skipped = 0
|
||||
private var recorder: HandCameraRecorder? = null
|
||||
private var rawFrame: Bitmap? = null
|
||||
private var turnedFrame: Bitmap? = null
|
||||
|
||||
private val gravity = object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val v = Vec3(event.values[0], event.values[1], event.values[2])
|
||||
val last = up
|
||||
up = if (event.sensor.type == Sensor.TYPE_GRAVITY || last == null) v else last + (v - last) * ACCEL_SMOOTHING
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
|
||||
}
|
||||
|
||||
private val heat: Any? = if (Build.VERSION.SDK_INT >= 29) PowerManager.OnThermalStatusChangedListener { thermal = it } else null
|
||||
|
||||
init {
|
||||
registry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
fun start(side: String?) {
|
||||
val chosen = TrackingCameras.pick(HandTracking.cameras(context), side)
|
||||
if (chosen == null) {
|
||||
broken.value = true
|
||||
return
|
||||
}
|
||||
camera = chosen
|
||||
zoom = chosen.zoom()
|
||||
recorder = HandCameraRecorder.start(context, output, preview, chosen)
|
||||
val sensor = sensors?.getDefaultSensor(Sensor.TYPE_GRAVITY) ?: sensors?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
if (sensor != null) sensors?.registerListener(gravity, sensor, SensorManager.SENSOR_DELAY_UI)
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
thermal = power?.currentThermalStatus ?: 0
|
||||
power?.addThermalStatusListener(ContextCompat.getMainExecutor(context), heat as PowerManager.OnThermalStatusChangedListener)
|
||||
}
|
||||
executor.execute { prepare() }
|
||||
val future = ProcessCameraProvider.getInstance(context)
|
||||
future.addListener({
|
||||
if (stopped) return@addListener
|
||||
runCatching { bind(future.get(), chosen) }.onFailure {
|
||||
Log.w(TAG, "camera ${chosen.id} could not start", it)
|
||||
broken.value = true
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
sensors?.unregisterListener(gravity)
|
||||
if (Build.VERSION.SDK_INT >= 29) power?.removeThermalStatusListener(heat as PowerManager.OnThermalStatusChangedListener)
|
||||
analysis?.let {
|
||||
it.clearAnalyzer()
|
||||
runCatching { provider?.unbind(it) }
|
||||
}
|
||||
registry.currentState = Lifecycle.State.DESTROYED
|
||||
executor.execute {
|
||||
runCatching { landmarker?.close() }
|
||||
landmarker = null
|
||||
hands?.close()
|
||||
hands = null
|
||||
shm?.close()
|
||||
shm = null
|
||||
recorder?.close()
|
||||
recorder = null
|
||||
}
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
private fun bind(cameras: ProcessCameraProvider, chosen: TrackingCamera) {
|
||||
val selector = CameraSelector.Builder()
|
||||
.addCameraFilter { infos -> infos.filter { Camera2CameraInfo.from(it).cameraId == chosen.id } }
|
||||
.build()
|
||||
val builder = ImageAnalysis.Builder()
|
||||
val extender = Camera2Interop.Extender(builder)
|
||||
chosen.fps?.let { extender.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(it.first, it.second)) }
|
||||
recorder?.let { extender.setSessionCaptureCallback(it.callback) }
|
||||
val use = builder
|
||||
.setResolutionSelector(
|
||||
ResolutionSelector.Builder()
|
||||
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
|
||||
.setResolutionStrategy(ResolutionStrategy(Size(ANALYSIS_WIDTH, ANALYSIS_HEIGHT), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER))
|
||||
.build(),
|
||||
)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
|
||||
.build()
|
||||
use.setAnalyzer(executor, ::analyze)
|
||||
val bound = cameras.bindToLifecycle(this, selector, use)
|
||||
if (zoom < 1f) bound.cameraControl.setZoomRatio(zoom)
|
||||
provider = cameras
|
||||
analysis = use
|
||||
registry.currentState = Lifecycle.State.RESUMED
|
||||
Log.i(TAG, "camera ${chosen.id} (${if (chosen.front) "front" else "rear"}, ${chosen.fovDegrees.toInt()}°, zoom $zoom, ${chosen.fps} fps) " +
|
||||
if (preview) "checking placement" else "tracking hands")
|
||||
}
|
||||
|
||||
private fun prepare() {
|
||||
if (stopped) return
|
||||
runCatching {
|
||||
val bytes = context.assets.open(MODEL).use { it.readBytes() }
|
||||
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
|
||||
model.rewind()
|
||||
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
|
||||
.setRunningMode(RunningMode.VIDEO)
|
||||
.setNumPoses(1)
|
||||
.setMinPoseDetectionConfidence(MIN_CONFIDENCE)
|
||||
.setMinPosePresenceConfidence(MIN_CONFIDENCE)
|
||||
.setOutputSegmentationMasks(false)
|
||||
.build()
|
||||
landmarker = PoseLandmarker.createFromOptions(context, options)
|
||||
if (output != null) shm = HandCameraShm.open(output)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "pose model could not load", it)
|
||||
broken.value = true
|
||||
}
|
||||
if (landmarker != null) hands = runCatching { HandModel.open(context) }.onFailure { Log.w(TAG, "hand model could not load", it) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
val chosen = camera ?: return
|
||||
val pose = landmarker ?: return
|
||||
val arrival = System.nanoTime()
|
||||
if (stopped || arrival - lastRun < interval()) {
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
lastRun = arrival
|
||||
val exposed = exposure(image.imageInfo.timestamp, arrival, chosen)
|
||||
val stampMs = maxOf(exposed / 1_000_000L, lastStampMs + 1)
|
||||
lastStampMs = stampMs
|
||||
val gravity = up
|
||||
val placement = if (gravity != null) CameraGeometry.placement(chosen.front, gravity) else Placement.UNKNOWN
|
||||
val turns = if (gravity != null) CameraGeometry.uprightTurns(chosen.front, chosen.orientation, gravity)
|
||||
else image.imageInfo.rotationDegrees / 90
|
||||
val frame = upright(raw(image), turns)
|
||||
val result = pose.detectForVideo(BitmapImageBuilder(frame).build(), stampMs)
|
||||
val inferred = System.nanoTime()
|
||||
val found = result.landmarks().firstOrNull()
|
||||
val marks = found?.map { Landmark(it.x(), it.y(), it.visibility().orElse(0f)) }
|
||||
val world = result.worldLandmarks().firstOrNull()?.map { WorldPoint(it.x(), it.y(), it.z()) }
|
||||
val k = chosen.intrinsics(image.width, image.height, zoom, turns)
|
||||
val model = hands
|
||||
val fixes = if (marks != null && model != null) model.find(frame, marks, k, stampMs) else null
|
||||
val handsDone = System.nanoTime()
|
||||
fixes?.forEachIndexed { side, fix ->
|
||||
if (fix != null) {
|
||||
heldDepth[side] = fix.palm.z
|
||||
heldAt[side] = exposed
|
||||
}
|
||||
}
|
||||
val held = FloatArray(2) { if (exposed - heldAt[it] <= HOLD_NS) heldDepth[it] else Float.NaN }
|
||||
val body = if (marks != null && world != null && gravity != null) {
|
||||
BodyPose.solve(marks, world, k, CameraLevel(CameraGeometry.upInCamera(chosen.front, chosen.orientation, turns, gravity)),
|
||||
if (model != null) fixes ?: arrayOfNulls(2) else null, held)
|
||||
} else null
|
||||
shm?.write(exposed, body, bareHands)
|
||||
if (preview) state.value = View(frame, chosen.front, marks, placement, PlacementAdvice.of(placement, body))
|
||||
recorder?.frame(HandCameraRecorder.Frame(exposed, image.imageInfo.timestamp, arrival, inferred, System.nanoTime(), skipped,
|
||||
thermal, turns, placement, gravity, k,
|
||||
found?.map { floatArrayOf(it.x(), it.y(), it.z(), it.visibility().orElse(0f), it.presence().orElse(0f)) },
|
||||
world, body, handsDone, fixes), frame)
|
||||
skipped = 0
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "camera frame skipped", e)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun raw(image: ImageProxy): Bitmap {
|
||||
val plane = image.planes[0]
|
||||
if (plane.pixelStride != 4 || plane.rowStride != image.width * 4) return image.toBitmap()
|
||||
val bitmap = rawFrame?.takeIf { it.width == image.width && it.height == image.height }
|
||||
?: Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).also { rawFrame = it }
|
||||
plane.buffer.rewind()
|
||||
bitmap.copyPixelsFromBuffer(plane.buffer)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun upright(raw: Bitmap, turns: Int): Bitmap {
|
||||
val quarter = Math.floorMod(turns, 4)
|
||||
if (quarter == 0) return if (preview) raw.copy(Bitmap.Config.ARGB_8888, false) else raw
|
||||
val width = if (quarter % 2 == 1) raw.height else raw.width
|
||||
val height = if (quarter % 2 == 1) raw.width else raw.height
|
||||
val target = if (preview) Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
else turnedFrame?.takeIf { it.width == width && it.height == height }
|
||||
?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { turnedFrame = it }
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(90f * quarter)
|
||||
when (quarter) {
|
||||
1 -> matrix.postTranslate(raw.height.toFloat(), 0f)
|
||||
2 -> matrix.postTranslate(raw.width.toFloat(), raw.height.toFloat())
|
||||
else -> matrix.postTranslate(0f, raw.width.toFloat())
|
||||
}
|
||||
Canvas(target).drawBitmap(raw, matrix, null)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exposure(stamp: Long, arrival: Long, chosen: TrackingCamera): Long {
|
||||
val monotonic = if (chosen.realtime) stamp - (SystemClock.elapsedRealtimeNanos() - System.nanoTime()) else stamp
|
||||
return if (abs(arrival - monotonic) < MAX_DELAY_NS) monotonic else arrival - TYPICAL_DELAY_NS
|
||||
}
|
||||
|
||||
private fun interval(): Long {
|
||||
val fps = when {
|
||||
thermal >= SEVERE -> HOT_FPS
|
||||
thermal >= MODERATE -> WARM_FPS
|
||||
else -> FPS
|
||||
}
|
||||
return 1_000_000_000L / fps - SLACK_NS
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HandCamera"
|
||||
private const val MODEL = "handtracking/pose_landmarker_lite.task"
|
||||
private const val ANALYSIS_WIDTH = 960
|
||||
private const val ANALYSIS_HEIGHT = 720
|
||||
private const val MIN_CONFIDENCE = 0.5f
|
||||
private const val FPS = 15
|
||||
private const val WARM_FPS = 10
|
||||
private const val HOT_FPS = 6
|
||||
private const val MODERATE = 2
|
||||
private const val SEVERE = 3
|
||||
private const val SLACK_NS = 8_000_000L
|
||||
private const val MAX_DELAY_NS = 1_000_000_000L
|
||||
private const val TYPICAL_DELAY_NS = 60_000_000L
|
||||
private const val ACCEL_SMOOTHING = 0.1f
|
||||
private const val HOLD_NS = 500_000_000L
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.CaptureResult
|
||||
import android.hardware.camera2.TotalCaptureResult
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal class HandCameraRecorder private constructor(private val dir: File, private val traceFlag: File?) {
|
||||
class Capture(val exposureNs: Long, val frameDurationNs: Long, val iso: Int)
|
||||
|
||||
class Frame(
|
||||
val exposureNs: Long,
|
||||
val sensorNs: Long,
|
||||
val arrivalNs: Long,
|
||||
val inferredNs: Long,
|
||||
val doneNs: Long,
|
||||
val skipped: Int,
|
||||
val thermal: Int,
|
||||
val turns: Int,
|
||||
val placement: Placement,
|
||||
val gravity: Vec3?,
|
||||
val intrinsics: Intrinsics?,
|
||||
val marks: List<FloatArray>?,
|
||||
val world: List<WorldPoint>?,
|
||||
val body: BodyFrame?,
|
||||
val handsNs: Long = 0L,
|
||||
val fixes: Array<HandFix?>? = null,
|
||||
)
|
||||
|
||||
private val writer = Executors.newSingleThreadExecutor { Thread(it, "hand-camera-record") }
|
||||
private val lines: BufferedWriter = File(dir, "frames.jsonl").bufferedWriter()
|
||||
private val busy = AtomicBoolean(false)
|
||||
private val captures = object : LinkedHashMap<Long, Capture>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, Capture>?) = size > KEEP_CAPTURES
|
||||
}
|
||||
private var count = 0
|
||||
@Volatile private var closed = false
|
||||
|
||||
val callback = object : CameraCaptureSession.CaptureCallback() {
|
||||
override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) {
|
||||
val stamp = result.get(CaptureResult.SENSOR_TIMESTAMP) ?: return
|
||||
val capture = Capture(result.get(CaptureResult.SENSOR_EXPOSURE_TIME) ?: 0L,
|
||||
result.get(CaptureResult.SENSOR_FRAME_DURATION) ?: 0L, result.get(CaptureResult.SENSOR_SENSITIVITY) ?: 0)
|
||||
synchronized(captures) { captures[stamp] = capture }
|
||||
}
|
||||
}
|
||||
|
||||
fun frame(frame: Frame, image: Bitmap) {
|
||||
if (closed || count >= MAX_FRAMES) return
|
||||
val index = ++count
|
||||
val capture = synchronized(captures) { captures[frame.sensorNs] }
|
||||
val small = if (busy.compareAndSet(false, true)) runCatching { shrink(image) }.getOrNull().also { if (it == null) busy.set(false) } else null
|
||||
val name = if (small != null) "%05d.jpg".format(Locale.ROOT, index) else null
|
||||
val line = json(index, frame, capture, name)
|
||||
val queued = runCatching { writer.execute {
|
||||
if (small != null) {
|
||||
runCatching { FileOutputStream(File(dir, name!!)).use { small.compress(Bitmap.CompressFormat.JPEG, QUALITY, it) } }
|
||||
small.recycle()
|
||||
busy.set(false)
|
||||
}
|
||||
runCatching {
|
||||
lines.write(line)
|
||||
lines.newLine()
|
||||
if (index % FLUSH_EVERY == 0) lines.flush()
|
||||
}
|
||||
} }
|
||||
if (queued.isFailure) {
|
||||
small?.recycle()
|
||||
busy.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
traceFlag?.delete()
|
||||
writer.execute { runCatching { lines.close() } }
|
||||
writer.shutdown()
|
||||
Log.i(TAG, "recorded $count frames to $dir")
|
||||
}
|
||||
|
||||
private fun shrink(image: Bitmap): Bitmap {
|
||||
val scale = SIDE.toFloat() / max(image.width, image.height)
|
||||
if (scale >= 1f) return image.copy(Bitmap.Config.ARGB_8888, false)
|
||||
return Bitmap.createScaledBitmap(image, (image.width * scale).roundToInt(), (image.height * scale).roundToInt(), true)
|
||||
}
|
||||
|
||||
private fun json(index: Int, f: Frame, capture: Capture?, jpg: String?): String = buildString {
|
||||
append("{\"i\":").append(index)
|
||||
append(",\"exp\":").append(f.exposureNs)
|
||||
append(",\"sensor\":").append(f.sensorNs)
|
||||
append(",\"arr\":").append(f.arrivalNs)
|
||||
append(",\"inf\":").append(f.inferredNs)
|
||||
append(",\"done\":").append(f.doneNs)
|
||||
append(",\"hand\":").append(f.handsNs)
|
||||
append(",\"skip\":").append(f.skipped)
|
||||
append(",\"heat\":").append(f.thermal)
|
||||
append(",\"turns\":").append(f.turns)
|
||||
append(",\"place\":\"").append(f.placement.name).append('"')
|
||||
append(",\"g\":").vec(f.gravity)
|
||||
f.intrinsics?.let { append(",\"k\":[").num(it.fx).append(',').num(it.cx).append(',').num(it.cy).append(',').append(it.width).append(',').append(it.height).append(']') }
|
||||
capture?.let { append(",\"ae\":[").append(it.exposureNs).append(',').append(it.frameDurationNs).append(',').append(it.iso).append(']') }
|
||||
append(",\"lm\":")
|
||||
if (f.marks == null) append("null") else {
|
||||
append('[')
|
||||
f.marks.forEachIndexed { i, m ->
|
||||
if (i > 0) append(',')
|
||||
append('[')
|
||||
m.forEachIndexed { j, v -> if (j > 0) append(','); num(v) }
|
||||
append(']')
|
||||
}
|
||||
append(']')
|
||||
}
|
||||
append(",\"wl\":")
|
||||
if (f.world == null) append("null") else {
|
||||
append('[')
|
||||
f.world.forEachIndexed { i, w ->
|
||||
if (i > 0) append(',')
|
||||
append('[').num(w.x).append(',').num(w.y).append(',').num(w.z).append(']')
|
||||
}
|
||||
append(']')
|
||||
}
|
||||
f.body?.let { b ->
|
||||
append(",\"hands\":[").vec(b.hands[0]).append(',').vec(b.hands[1]).append(']')
|
||||
append(",\"face\":"); if (b.faceYaw == null) append("null") else num(b.faceYaw)
|
||||
append(",\"est\":[").append(b.estimated[0]).append(',').append(b.estimated[1]).append(']')
|
||||
append(",\"room\":").num(b.edgeRoom)
|
||||
append(",\"dist\":").num(b.distance)
|
||||
append(",\"span\":").num(b.shoulderSpan)
|
||||
append(",\"joints\":[")
|
||||
b.joints.forEachIndexed { i, j ->
|
||||
if (i > 0) append(',')
|
||||
if (j == null) append("null") else {
|
||||
append('[')
|
||||
j.forEachIndexed { k, v -> if (k > 0) append(','); num(v) }
|
||||
append(']')
|
||||
}
|
||||
}
|
||||
append(']')
|
||||
}
|
||||
f.fixes?.let { fixes ->
|
||||
append(",\"fix\":[")
|
||||
fixes.forEachIndexed { i, fix ->
|
||||
if (i > 0) append(',')
|
||||
if (fix == null) append("null") else {
|
||||
append("{\"palm\":").vec(fix.palm).append(",\"at\":[").num(fix.x).append(',').num(fix.y).append("],\"size\":").num(fix.extent)
|
||||
append(",\"pts\":[")
|
||||
fix.points.forEachIndexed { j, v -> if (j > 0) append(','); num(v) }
|
||||
append("],\"world\":[")
|
||||
fix.world.forEachIndexed { j, w -> if (j > 0) append(','); num(w.x).append(',').num(w.y).append(',').num(w.z) }
|
||||
append("]}")
|
||||
}
|
||||
}
|
||||
append(']')
|
||||
}
|
||||
append(",\"jpg\":"); if (jpg == null) append("null") else append('"').append(jpg).append('"')
|
||||
append('}')
|
||||
}
|
||||
|
||||
private fun StringBuilder.num(v: Float): StringBuilder = if (v.isFinite()) append(v) else append("null")
|
||||
|
||||
private fun StringBuilder.vec(v: Vec3?): StringBuilder =
|
||||
if (v == null) append("null") else append('[').num(v.x).append(',').num(v.y).append(',').num(v.z).append(']')
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HandCamera"
|
||||
const val FLAG = "handcam-record"
|
||||
const val TRACE_FLAG = "droiddeck-handtrace.on"
|
||||
private const val MAX_FRAMES = 5400
|
||||
private const val KEEP_CAPTURES = 32
|
||||
private const val SIDE = 640
|
||||
private const val QUALITY = 85
|
||||
private const val FLUSH_EVERY = 30
|
||||
|
||||
fun start(context: Context, output: File?, preview: Boolean, camera: TrackingCamera): HandCameraRecorder? {
|
||||
if (!File(context.filesDir, FLAG).exists()) return null
|
||||
return runCatching {
|
||||
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(Date())
|
||||
val dir = File(context.cacheDir, "handcam-rec/$stamp-${if (preview) "check" else "track"}").apply { mkdirs() }
|
||||
File(dir, "camera.json").writeText("{\"id\":\"${camera.id}\",\"front\":${camera.front},\"orientation\":${camera.orientation}," +
|
||||
"\"focal\":${camera.focalPixels},\"active\":[${camera.activeWidth},${camera.activeHeight}]," +
|
||||
"\"fps\":${camera.fps?.let { "[${it.first},${it.second}]" } ?: "null"}}")
|
||||
val flag = output?.parentFile?.let { File(it, TRACE_FLAG) }?.also { it.createNewFile() }
|
||||
Log.i(TAG, "recording hand camera to $dir")
|
||||
HandCameraRecorder(dir, flag)
|
||||
}.onFailure { Log.w(TAG, "hand camera recording unavailable", it) }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.MappedByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
internal class HandCameraShm private constructor(
|
||||
private val file: RandomAccessFile,
|
||||
private val map: MappedByteBuffer,
|
||||
private val fence: () -> Unit,
|
||||
) {
|
||||
private var seq = map.getInt(SEQ).let { if (it and 1 != 0) it + 1 else it }
|
||||
private var frame = 0
|
||||
|
||||
init {
|
||||
begin()
|
||||
map.putInt(MAGIC_AT, MAGIC)
|
||||
map.putInt(VERSION_AT, VERSION)
|
||||
for (at in FLAGS until SIZE step 4) map.putInt(at, 0)
|
||||
end()
|
||||
}
|
||||
|
||||
fun write(timeNs: Long, body: BodyFrame?, bareHands: Boolean = false) {
|
||||
begin()
|
||||
val face = body?.faceYaw
|
||||
map.putInt(FLAGS, RUNNING or (if (face != null) FACE else 0) or (if (bareHands) BARE_HANDS else 0))
|
||||
map.putLong(TIME, timeNs)
|
||||
map.putInt(FRAME, ++frame)
|
||||
map.putFloat(FACE_YAW, face ?: 0f)
|
||||
for (side in 0..1) {
|
||||
val at = HANDS + side * HAND_SIZE
|
||||
val hand: Vec3? = body?.hands?.getOrNull(side)
|
||||
val joints = if (hand != null) body?.joints?.getOrNull(side)?.takeIf { it.size == JOINT_FLOATS } else null
|
||||
val seen = if (body?.estimated?.getOrNull(side) == true) SEEN or ESTIMATED else SEEN
|
||||
map.putInt(at, if (hand == null) 0 else if (joints != null) seen or JOINTS else seen)
|
||||
map.putFloat(at + 4, hand?.x ?: 0f)
|
||||
map.putFloat(at + 8, hand?.y ?: 0f)
|
||||
map.putFloat(at + 12, hand?.z ?: 0f)
|
||||
for (i in 0 until JOINT_FLOATS) map.putFloat(at + JOINTS_AT + i * 4, joints?.get(i) ?: 0f)
|
||||
}
|
||||
end()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
begin()
|
||||
for (at in FLAGS until SIZE step 4) map.putInt(at, 0)
|
||||
end()
|
||||
runCatching { file.close() }
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
map.putInt(SEQ, ++seq)
|
||||
fence()
|
||||
}
|
||||
|
||||
private fun end() {
|
||||
fence()
|
||||
map.putInt(SEQ, ++seq)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILE = "droiddeck-handcam"
|
||||
const val SIZE = 568
|
||||
private const val MAGIC = 0x4D434844
|
||||
private const val VERSION = 2
|
||||
private const val MAGIC_AT = 0
|
||||
private const val VERSION_AT = 4
|
||||
private const val SEQ = 8
|
||||
private const val FLAGS = 12
|
||||
private const val TIME = 16
|
||||
private const val FRAME = 24
|
||||
private const val FACE_YAW = 28
|
||||
private const val HANDS = 32
|
||||
private const val HAND_SIZE = 268
|
||||
private const val JOINTS_AT = 16
|
||||
private const val JOINT_FLOATS = BodyPose.HAND_POINTS * 3
|
||||
private const val RUNNING = 1
|
||||
private const val FACE = 2
|
||||
private const val BARE_HANDS = 4
|
||||
private const val SEEN = 1
|
||||
private const val ESTIMATED = 2
|
||||
private const val JOINTS = 4
|
||||
|
||||
fun open(path: File, fence: () -> Unit = FakeInputWriter::storeFence): HandCameraShm? = runCatching {
|
||||
path.parentFile?.mkdirs()
|
||||
val file = RandomAccessFile(path, "rw")
|
||||
try {
|
||||
file.setLength(SIZE.toLong())
|
||||
val map = file.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong())
|
||||
map.order(ByteOrder.LITTLE_ENDIAN)
|
||||
HandCameraShm(file, map, fence)
|
||||
} catch (e: Exception) {
|
||||
file.close()
|
||||
throw e
|
||||
}
|
||||
}.onFailure { Log.w("HandCamera", "hand camera file unavailable: $path", it) }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import com.google.mediapipe.framework.image.BitmapImageBuilder
|
||||
import com.google.mediapipe.tasks.components.containers.Landmark as MpLandmark
|
||||
import com.google.mediapipe.tasks.components.containers.NormalizedLandmark
|
||||
import com.google.mediapipe.tasks.core.BaseOptions
|
||||
import com.google.mediapipe.tasks.core.Delegate
|
||||
import com.google.mediapipe.tasks.vision.core.RunningMode
|
||||
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
|
||||
internal class HandModel private constructor(private val landmarkers: List<HandLandmarker>, private val starter: HandLandmarker) {
|
||||
private class Track(val x: Float, val y: Float, val size: Float)
|
||||
|
||||
private val crops = List(2) { Bitmap.createBitmap(CROP, CROP, Bitmap.Config.ARGB_8888) }
|
||||
private val startCrop = Bitmap.createBitmap(START_CROP, START_CROP, Bitmap.Config.ARGB_8888)
|
||||
private val tracks = arrayOfNulls<Track>(2)
|
||||
private val starts = IntArray(2)
|
||||
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
private val matrix = Matrix()
|
||||
|
||||
fun find(frame: Bitmap, marks: List<Landmark>, k: Intrinsics, stampMs: Long): Array<HandFix?> {
|
||||
val fixes = arrayOfNulls<HandFix>(2)
|
||||
if (marks.size <= BodyPose.RIGHT_INDEX) return fixes
|
||||
val shoulders = hypot((marks[BodyPose.LEFT_SHOULDER].x - marks[BodyPose.RIGHT_SHOULDER].x) * k.width,
|
||||
(marks[BodyPose.LEFT_SHOULDER].y - marks[BodyPose.RIGHT_SHOULDER].y) * k.height)
|
||||
for (side in 0..1) {
|
||||
val wrist = marks[WRISTS[side]]
|
||||
val wx = wrist.x * k.width
|
||||
val wy = wrist.y * k.height
|
||||
val fix = tracks[side]?.let { follow(frame, it, side, k, stampMs) }?.takeIf { belongs(it, marks, side, shoulders, k) }
|
||||
?: start(frame, marks, side, wx, wy, shoulders, k)?.takeIf { belongs(it, marks, side, shoulders, k) }
|
||||
if (fix == null) {
|
||||
tracks[side] = null
|
||||
continue
|
||||
}
|
||||
fixes[side] = fix
|
||||
starts[side] = 0
|
||||
tracks[side] = Track(fix.x, fix.y, max(fix.extent, shoulders * MIN_HAND))
|
||||
}
|
||||
val left = fixes[0]
|
||||
val right = fixes[1]
|
||||
if (left != null && right != null && hypot(left.x - right.x, left.y - right.y) < max(left.extent, right.extent) * SAME_HAND) {
|
||||
val drop = if (gap(left, marks, 0, k) <= gap(right, marks, 1, k)) 1 else 0
|
||||
fixes[drop] = null
|
||||
tracks[drop] = null
|
||||
}
|
||||
return fixes
|
||||
}
|
||||
|
||||
fun close() {
|
||||
landmarkers.forEach { runCatching { it.close() } }
|
||||
runCatching { starter.close() }
|
||||
crops.forEach { it.recycle() }
|
||||
startCrop.recycle()
|
||||
}
|
||||
|
||||
private fun follow(frame: Bitmap, track: Track, side: Int, k: Intrinsics, stampMs: Long): HandFix? {
|
||||
val size = max(track.size * CROP_HANDS, MIN_CROP)
|
||||
val x0 = track.x - size / 2
|
||||
val y0 = track.y - size / 2
|
||||
draw(frame, crops[side], x0, y0, size)
|
||||
val result = landmarkers[side].detectForVideo(BitmapImageBuilder(crops[side]).build(), stampMs)
|
||||
val found = result.landmarks().firstOrNull() ?: return null
|
||||
val world = result.worldLandmarks().firstOrNull() ?: return null
|
||||
return fixOf(found, world, x0, y0, size, k)
|
||||
}
|
||||
|
||||
private fun start(frame: Bitmap, marks: List<Landmark>, side: Int, wx: Float, wy: Float, shoulders: Float, k: Intrinsics): HandFix? {
|
||||
val wrist = marks[WRISTS[side]]
|
||||
if (wrist.visibility < MIN_VISIBILITY || shoulders < 1f) return null
|
||||
if (wrist.x < -EDGE || wrist.x > 1f + EDGE || wrist.y < -EDGE || wrist.y > 1f + EDGE) return null
|
||||
val wide = starts[side]++ % 2 == 0
|
||||
val tx = (marks[PINKIES[side]].x + marks[INDEXES[side]].x) / 2 * k.width - wx
|
||||
val ty = (marks[PINKIES[side]].y + marks[INDEXES[side]].y) / 2 * k.height - wy
|
||||
val length = hypot(tx, ty)
|
||||
val reach = if (wide) 0f else shoulders * TOWARD_FINGERS
|
||||
val cx = if (length > 1e-3f) wx + tx / length * reach else wx
|
||||
val cy = if (length > 1e-3f) wy + ty / length * reach else wy
|
||||
val size = max(shoulders * if (wide) WIDE_SHOULDERS else START_SHOULDERS, MIN_CROP)
|
||||
val x0 = cx - size / 2
|
||||
val y0 = cy - size / 2
|
||||
draw(frame, startCrop, x0, y0, size)
|
||||
val result = starter.detect(BitmapImageBuilder(startCrop).build())
|
||||
var best: HandFix? = null
|
||||
var bestGap = Float.MAX_VALUE
|
||||
result.landmarks().forEachIndexed { i, found ->
|
||||
val world = result.worldLandmarks().getOrNull(i) ?: return@forEachIndexed
|
||||
val fix = fixOf(found, world, x0, y0, size, k) ?: return@forEachIndexed
|
||||
val gap = hypot(fix.points[0] - wx, fix.points[1] - wy)
|
||||
if (gap < bestGap) {
|
||||
best = fix
|
||||
bestGap = gap
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun draw(frame: Bitmap, crop: Bitmap, x0: Float, y0: Float, size: Float) {
|
||||
crop.eraseColor(Color.BLACK)
|
||||
matrix.setTranslate(-x0, -y0)
|
||||
matrix.postScale(crop.width / size, crop.height / size)
|
||||
Canvas(crop).drawBitmap(frame, matrix, paint)
|
||||
}
|
||||
|
||||
private fun fixOf(found: List<NormalizedLandmark>, world: List<MpLandmark>, x0: Float, y0: Float, size: Float, k: Intrinsics): HandFix? {
|
||||
if (found.size < BodyPose.HAND_POINTS || world.size < BodyPose.HAND_POINTS) return null
|
||||
val points = FloatArray(BodyPose.HAND_POINTS * 2) { i ->
|
||||
val p = found[i / 2]
|
||||
if (i % 2 == 0) x0 + p.x() * size else y0 + p.y() * size
|
||||
}
|
||||
return BodyPose.handFix(points, world.map { WorldPoint(it.x(), it.y(), it.z()) }, k)
|
||||
}
|
||||
|
||||
private fun belongs(fix: HandFix, marks: List<Landmark>, side: Int, shoulders: Float, k: Intrinsics): Boolean {
|
||||
val own = marks[WRISTS[side]]
|
||||
if (hypot(fix.x - own.x * k.width, fix.y - own.y * k.height) > max(fix.extent, shoulders) * MAX_WRIST_GAP) return false
|
||||
val other = marks[WRISTS[1 - side]]
|
||||
if (other.visibility < MIN_VISIBILITY || other.x !in 0f..1f || other.y !in 0f..1f) return true
|
||||
return hypot(fix.points[0] - other.x * k.width, fix.points[1] - other.y * k.height) >=
|
||||
hypot(fix.points[0] - own.x * k.width, fix.points[1] - own.y * k.height)
|
||||
}
|
||||
|
||||
private fun gap(fix: HandFix, marks: List<Landmark>, side: Int, k: Intrinsics): Float =
|
||||
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
|
||||
|
||||
companion object {
|
||||
private const val MODEL = "handtracking/hand_landmarker.task"
|
||||
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
|
||||
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
|
||||
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
|
||||
private const val CROP = 256
|
||||
private const val START_CROP = 384
|
||||
private const val CROP_HANDS = 2.8f
|
||||
private const val MIN_CROP = 48f
|
||||
private const val START_SHOULDERS = 2.5f
|
||||
private const val WIDE_SHOULDERS = 4f
|
||||
private const val MIN_HAND = 0.25f
|
||||
private const val TOWARD_FINGERS = 0.35f
|
||||
private const val MAX_WRIST_GAP = 3f
|
||||
private const val SAME_HAND = 0.5f
|
||||
private const val EDGE = 0.05f
|
||||
private const val MIN_VISIBILITY = 0.5f
|
||||
private const val MIN_CONFIDENCE = 0.4f
|
||||
|
||||
fun open(context: Context): HandModel {
|
||||
val bytes = context.assets.open(MODEL).use { it.readBytes() }
|
||||
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
|
||||
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
|
||||
model.rewind()
|
||||
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
|
||||
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
|
||||
.setRunningMode(mode)
|
||||
.setNumHands(hands)
|
||||
.setMinHandDetectionConfidence(MIN_CONFIDENCE)
|
||||
.setMinHandPresenceConfidence(MIN_CONFIDENCE)
|
||||
.setMinTrackingConfidence(MIN_CONFIDENCE)
|
||||
.build())
|
||||
}
|
||||
return HandModel(List(2) { landmarker(RunningMode.VIDEO, 1) }, landmarker(RunningMode.IMAGE, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
internal object HandTracking {
|
||||
data class Settings(
|
||||
val enabled: Boolean = false,
|
||||
val asked: Boolean = false,
|
||||
val side: String? = null,
|
||||
val permitted: Boolean = false,
|
||||
val sides: Set<String> = emptySet(),
|
||||
) {
|
||||
val usable: Boolean get() = sides.isNotEmpty()
|
||||
val ready: Boolean get() = enabled && permitted && usable
|
||||
}
|
||||
|
||||
private val state = MutableStateFlow(Settings())
|
||||
val settings: StateFlow<Settings> = state.asStateFlow()
|
||||
private val checks = MutableStateFlow(0)
|
||||
val checkRequests: StateFlow<Int> = checks.asStateFlow()
|
||||
@Volatile private var cameras: List<TrackingCamera>? = null
|
||||
|
||||
val supported: Boolean get() = Build.VERSION.SDK_INT >= 31
|
||||
|
||||
fun load(context: Context): Settings {
|
||||
val prefs = ControllerPrefs.prefs(context)
|
||||
val found = cameras(context)
|
||||
val next = Settings(
|
||||
enabled = prefs.getBoolean(ENABLED, false),
|
||||
asked = prefs.getBoolean(ASKED, false),
|
||||
side = prefs.getString(SIDE, null),
|
||||
permitted = permitted(context),
|
||||
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
|
||||
)
|
||||
state.value = next
|
||||
return next
|
||||
}
|
||||
|
||||
fun cameras(context: Context): List<TrackingCamera> =
|
||||
cameras ?: TrackingCameras.list(context.applicationContext).also { cameras = it }
|
||||
|
||||
fun camera(context: Context): TrackingCamera? = TrackingCameras.pick(cameras(context), state.value.side)
|
||||
|
||||
fun permitted(context: Context): Boolean =
|
||||
context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun refreshPermission(context: Context) {
|
||||
state.value = state.value.copy(permitted = permitted(context))
|
||||
}
|
||||
|
||||
fun allow(context: Context, side: String?) {
|
||||
val edit = ControllerPrefs.prefs(context).edit().putBoolean(ASKED, true).putBoolean(ENABLED, true)
|
||||
if (side != null) edit.putString(SIDE, side)
|
||||
edit.apply()
|
||||
load(context)
|
||||
}
|
||||
|
||||
fun decline(context: Context) {
|
||||
ControllerPrefs.prefs(context).edit().putBoolean(ASKED, true).putBoolean(ENABLED, false).apply()
|
||||
load(context)
|
||||
}
|
||||
|
||||
fun setEnabled(context: Context, on: Boolean) {
|
||||
ControllerPrefs.prefs(context).edit().putBoolean(ASKED, true).putBoolean(ENABLED, on).apply()
|
||||
load(context)
|
||||
}
|
||||
|
||||
fun setSide(context: Context, side: String) {
|
||||
ControllerPrefs.prefs(context).edit().putString(SIDE, side).apply()
|
||||
load(context)
|
||||
}
|
||||
|
||||
fun shouldAsk(settings: Settings, headset: Boolean): Boolean = headset && !settings.asked && settings.usable
|
||||
|
||||
fun wanted(context: Context): Boolean = supported && load(context).ready
|
||||
|
||||
fun requestCheck() {
|
||||
checks.value++
|
||||
}
|
||||
|
||||
private const val ENABLED = "handCamera"
|
||||
private const val ASKED = "handCameraAsked"
|
||||
private const val SIDE = "handCameraSide"
|
||||
}
|
||||
|
||||
internal enum class Advice { STARTING, STAND_UP, UPRIGHT, NOT_SEEN, MOVE_BACK, MOVE_CLOSER, SHOW_HANDS, GOOD }
|
||||
|
||||
internal object PlacementAdvice {
|
||||
const val MIN_SPAN = 0.12f
|
||||
const val MAX_SPAN = 0.26f
|
||||
const val MIN_ROOM = 0.05f
|
||||
|
||||
fun of(placement: Placement, body: BodyFrame?): Advice = when {
|
||||
placement == Placement.FACE_UP || placement == Placement.FACE_DOWN -> Advice.STAND_UP
|
||||
placement == Placement.POINTS_UP || placement == Placement.POINTS_DOWN -> Advice.UPRIGHT
|
||||
body == null || !body.seen -> Advice.NOT_SEEN
|
||||
body.shoulderSpan > MAX_SPAN -> Advice.MOVE_BACK
|
||||
body.shoulderSpan < MIN_SPAN -> Advice.MOVE_CLOSER
|
||||
body.edgeRoom < MIN_ROOM -> Advice.MOVE_BACK
|
||||
body.hands.any { it == null } -> Advice.SHOW_HANDS
|
||||
else -> Advice.GOOD
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.droiddeck.launcher.handtracking
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.tan
|
||||
|
||||
internal class TrackingCamera(
|
||||
val id: String,
|
||||
val front: Boolean,
|
||||
val orientation: Int,
|
||||
val focalPixels: Float,
|
||||
val activeWidth: Int,
|
||||
val activeHeight: Int,
|
||||
val minZoom: Float,
|
||||
val realtime: Boolean,
|
||||
val fps: Pair<Int, Int>? = null,
|
||||
) {
|
||||
val fovDegrees: Float get() = Math.toDegrees(2.0 * atan(activeWidth / 2.0 / focalPixels)).toFloat()
|
||||
|
||||
fun zoom(): Float {
|
||||
if (minZoom >= 1f) return 1f
|
||||
val needed = (tan(Math.toRadians(fovDegrees / 2.0)) / tan(Math.toRadians(MAX_FOV / 2.0))).toFloat()
|
||||
return min(1f, max(minZoom, needed))
|
||||
}
|
||||
|
||||
fun intrinsics(bufferWidth: Int, bufferHeight: Int, zoom: Float, turns: Int): Intrinsics {
|
||||
val aspect = bufferWidth.toFloat() / bufferHeight
|
||||
val region = min(activeWidth.toFloat(), activeHeight * aspect) / zoom
|
||||
val f = focalPixels * bufferWidth / region
|
||||
val quarter = turns and 1 == 1
|
||||
val w = if (quarter) bufferHeight else bufferWidth
|
||||
val h = if (quarter) bufferWidth else bufferHeight
|
||||
return Intrinsics(f, f, w / 2f, h / 2f, w, h)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_FOV = 100.0
|
||||
}
|
||||
}
|
||||
|
||||
internal object TrackingCameras {
|
||||
const val REAR = "rear"
|
||||
const val FRONT = "front"
|
||||
private const val MAX_FPS = 30
|
||||
|
||||
fun list(context: Context): List<TrackingCamera> {
|
||||
val manager = context.getSystemService(CameraManager::class.java) ?: return emptyList()
|
||||
val ids = runCatching { manager.cameraIdList.toList() }.getOrDefault(emptyList())
|
||||
return ids.mapNotNull { id -> runCatching { describe(id, manager.getCameraCharacteristics(id)) }.getOrNull() }
|
||||
}
|
||||
|
||||
private fun describe(id: String, c: CameraCharacteristics): TrackingCamera? {
|
||||
val facing = c.get(CameraCharacteristics.LENS_FACING) ?: return null
|
||||
if (facing == CameraCharacteristics.LENS_FACING_EXTERNAL) return null
|
||||
val caps = c.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) ?: return null
|
||||
if (CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE !in caps) return null
|
||||
val active = c.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) ?: return null
|
||||
val pixels = c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE) ?: return null
|
||||
val physical = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) ?: return null
|
||||
val focal = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull() ?: return null
|
||||
val calibrated = c.get(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)?.firstOrNull()?.takeIf { it > 1f }
|
||||
val focalPixels = calibrated ?: (focal * pixels.width / physical.width)
|
||||
val minZoom = if (Build.VERSION.SDK_INT >= 30) c.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)?.lower ?: 1f else 1f
|
||||
val realtime = c.get(CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE) == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME
|
||||
val fps = steadyFps(c.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)?.map { it.lower to it.upper }.orEmpty())
|
||||
return TrackingCamera(id, facing == CameraCharacteristics.LENS_FACING_FRONT, c.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0,
|
||||
focalPixels, active.width(), active.height(), minZoom, realtime, fps)
|
||||
}
|
||||
|
||||
fun steadyFps(ranges: List<Pair<Int, Int>>): Pair<Int, Int>? =
|
||||
ranges.filter { it.second <= MAX_FPS }.maxWithOrNull(compareBy<Pair<Int, Int>> { it.first }.thenByDescending { it.second })
|
||||
|
||||
fun sides(cameras: List<TrackingCamera>): Set<String> = cameras.mapTo(LinkedHashSet()) { if (it.front) FRONT else REAR }
|
||||
|
||||
fun pick(cameras: List<TrackingCamera>, side: String?): TrackingCamera? {
|
||||
val wanted = cameras.filter { side == null || (it.front == (side == FRONT)) }.ifEmpty { cameras }
|
||||
val preferred = if (side == null) wanted.filter { !it.front }.ifEmpty { wanted } else wanted
|
||||
return preferred.maxWithOrNull(compareBy<TrackingCamera> { min(widest(it), TrackingCamera.MAX_FOV.toFloat()) }
|
||||
.thenBy { it.activeWidth.toLong() * it.activeHeight })
|
||||
}
|
||||
|
||||
private fun widest(c: TrackingCamera): Float =
|
||||
Math.toDegrees(2.0 * atan(tan(Math.toRadians(c.fovDegrees / 2.0)) / c.zoom())).toFloat()
|
||||
}
|
||||
|
||||
internal object CameraGeometry {
|
||||
fun axes(front: Boolean, orientation: Int, turns: Int): Pair<Vec3, Vec3> {
|
||||
val t = Math.toRadians(orientation.toDouble())
|
||||
val c = Math.cos(t).toFloat()
|
||||
val s = Math.sin(t).toFloat()
|
||||
var u = if (front) Vec3(-c, -s, 0f) else Vec3(c, -s, 0f)
|
||||
var v = if (front) Vec3(s, -c, 0f) else Vec3(-s, -c, 0f)
|
||||
repeat(Math.floorMod(turns, 4)) {
|
||||
val nu = v * -1f
|
||||
v = u
|
||||
u = nu
|
||||
}
|
||||
return u to v
|
||||
}
|
||||
|
||||
fun upInCamera(front: Boolean, orientation: Int, turns: Int, up: Vec3): Vec3 {
|
||||
val (u, v) = axes(front, orientation, turns)
|
||||
val n = up.normalized()
|
||||
return Vec3(dot(n, u), dot(n, v), dot(n, u.cross(v)))
|
||||
}
|
||||
|
||||
fun uprightTurns(front: Boolean, orientation: Int, up: Vec3): Int =
|
||||
(0..3).minBy { turns -> dot(up.normalized(), axes(front, orientation, turns).second) }
|
||||
|
||||
fun placement(front: Boolean, up: Vec3): Placement {
|
||||
val n = up.normalized()
|
||||
if (n.length() < 0.5f) return Placement.UNKNOWN
|
||||
if (n.z > FLAT) return Placement.FACE_UP
|
||||
if (n.z < -FLAT) return Placement.FACE_DOWN
|
||||
val elevation = Math.toDegrees(asin((if (front) n.z else -n.z).toDouble().coerceIn(-1.0, 1.0)))
|
||||
return when {
|
||||
elevation > STEEP -> Placement.POINTS_UP
|
||||
elevation < -STEEP -> Placement.POINTS_DOWN
|
||||
else -> Placement.STANDING
|
||||
}
|
||||
}
|
||||
|
||||
private fun dot(a: Vec3, b: Vec3) = a.x * b.x + a.y * b.y + a.z * b.z
|
||||
|
||||
private const val FLAT = 0.9f
|
||||
private const val STEEP = 45.0
|
||||
}
|
||||
|
||||
internal enum class Placement { UNKNOWN, STANDING, FACE_UP, FACE_DOWN, POINTS_UP, POINTS_DOWN }
|
||||
@@ -47,7 +47,6 @@ object ControllerPrefs {
|
||||
val qamButton: Boolean,
|
||||
val keyboardButton: Boolean,
|
||||
val selectSteam: Boolean,
|
||||
val joyConVr: Boolean,
|
||||
val snap: Boolean,
|
||||
val customLayout: Boolean,
|
||||
val mapping: Map<String, String>,
|
||||
@@ -68,7 +67,6 @@ object ControllerPrefs {
|
||||
qamButton = p.getBoolean("qamButton", true),
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
joyConVr = joyConVr(context),
|
||||
snap = p.getBoolean("snap", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
@@ -129,12 +127,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("selectSteam", on).apply()
|
||||
}
|
||||
|
||||
fun joyConVr(context: Context): Boolean = prefs(context).getBoolean("joyConVr", true)
|
||||
|
||||
fun setJoyConVr(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("joyConVr", on).apply()
|
||||
}
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ public class FakeInputWriter {
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40;
|
||||
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
|
||||
private static final int PAD_TYPE_SET = 0x80000000;
|
||||
private static final int LATENCY_OFFSET = IMU_OFFSET + 48;
|
||||
public static final int LATENCY_CONTROLLER = 1;
|
||||
public static final int LATENCY_TOUCH = 2;
|
||||
private static volatile int latency = 0;
|
||||
public static final int PAD_TYPE_DECK = 0;
|
||||
public static final int PAD_TYPE_STEAM = 1;
|
||||
public static final int PAD_TYPE_XBOX = 2;
|
||||
@@ -119,10 +115,6 @@ public class FakeInputWriter {
|
||||
|
||||
private static native void nativeStoreFence();
|
||||
|
||||
public static void storeFence() {
|
||||
nativeStoreFence();
|
||||
}
|
||||
|
||||
private final File eventFile;
|
||||
private final int slot;
|
||||
private final ByteBuffer buffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
|
||||
@@ -222,7 +214,6 @@ public class FakeInputWriter {
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
int type = padType;
|
||||
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
|
||||
data.putInt(LATENCY_OFFSET, latency);
|
||||
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
|
||||
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
|
||||
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
|
||||
@@ -523,22 +514,6 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
public static void setLowLatency(boolean controller, boolean touch) {
|
||||
int flags = (controller ? LATENCY_CONTROLLER : 0) | (touch ? LATENCY_TOUCH : 0);
|
||||
latency = flags;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = RING_SLOTS[0];
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(LATENCY_OFFSET, flags);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setTritonConnected(int slot, boolean connected) {
|
||||
tritonConnected = connected;
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
|
||||
object JoyCon {
|
||||
const val LEFT = 0
|
||||
const val RIGHT = 1
|
||||
|
||||
const val VR_TRIGGER = 1 shl 0
|
||||
const val VR_GRIP = 1 shl 1
|
||||
const val VR_A = 1 shl 2
|
||||
const val VR_B = 1 shl 3
|
||||
const val VR_STICK = 1 shl 4
|
||||
const val VR_SYSTEM = 1 shl 5
|
||||
const val VR_RUMBLE_SLOT = 0x100
|
||||
|
||||
private const val VENDOR = 0x057e
|
||||
private const val PRODUCT_LEFT = 0x2006
|
||||
private const val PRODUCT_RIGHT = 0x2007
|
||||
|
||||
private const val BTN_SOUTH = 0x130
|
||||
private const val BTN_EAST = 0x131
|
||||
private const val BTN_NORTH = 0x133
|
||||
private const val BTN_WEST = 0x134
|
||||
private const val BTN_Z = 0x135
|
||||
private const val BTN_TL = 0x136
|
||||
private const val BTN_TR = 0x137
|
||||
private const val BTN_TL2 = 0x138
|
||||
private const val BTN_TR2 = 0x139
|
||||
private const val BTN_SELECT = 0x13a
|
||||
private const val BTN_START = 0x13b
|
||||
private const val BTN_MODE = 0x13c
|
||||
private const val BTN_THUMBL = 0x13d
|
||||
private const val BTN_THUMBR = 0x13e
|
||||
private const val BTN_DPAD_UP = 0x220
|
||||
private const val BTN_DPAD_DOWN = 0x221
|
||||
private const val BTN_DPAD_LEFT = 0x222
|
||||
private const val BTN_DPAD_RIGHT = 0x223
|
||||
private val NO_VR_DEVICES = intArrayOf(PadBridge.NO_CONTROLLER, PadBridge.NO_CONTROLLER)
|
||||
private var vrOwner: Any? = null
|
||||
@Volatile private var vrDevices = NO_VR_DEVICES
|
||||
|
||||
@JvmStatic
|
||||
fun side(device: InputDevice?): Int = when {
|
||||
device == null || device.isVirtual || device.vendorId != VENDOR -> -1
|
||||
device.productId == PRODUCT_LEFT -> LEFT
|
||||
device.productId == PRODUCT_RIGHT -> RIGHT
|
||||
else -> -1
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun stickAxes(side: Int, device: InputDevice, source: Int): IntArray = when {
|
||||
side == LEFT -> intArrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y)
|
||||
device.getMotionRange(MotionEvent.AXIS_RX, source) != null -> intArrayOf(MotionEvent.AXIS_RX, MotionEvent.AXIS_RY)
|
||||
else -> intArrayOf(MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isRail(side: Int, scanCode: Int): Boolean = when (side) {
|
||||
LEFT -> scanCode == BTN_TR || scanCode == BTN_TR2
|
||||
RIGHT -> scanCode == BTN_TL || scanCode == BTN_TL2
|
||||
else -> false
|
||||
}
|
||||
|
||||
internal fun padKey(side: Int, scanCode: Int): Int = when (side) {
|
||||
LEFT -> when (scanCode) {
|
||||
BTN_TL -> KeyEvent.KEYCODE_BUTTON_L1
|
||||
BTN_TL2 -> KeyEvent.KEYCODE_BUTTON_L2
|
||||
BTN_SELECT -> KeyEvent.KEYCODE_BUTTON_SELECT
|
||||
BTN_THUMBL -> KeyEvent.KEYCODE_BUTTON_THUMBL
|
||||
BTN_Z -> KeyEvent.KEYCODE_BUTTON_Z
|
||||
BTN_DPAD_UP -> KeyEvent.KEYCODE_DPAD_UP
|
||||
BTN_DPAD_DOWN -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||
BTN_DPAD_LEFT -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||
BTN_DPAD_RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
RIGHT -> when (scanCode) {
|
||||
BTN_SOUTH -> KeyEvent.KEYCODE_BUTTON_A
|
||||
BTN_EAST -> KeyEvent.KEYCODE_BUTTON_B
|
||||
BTN_NORTH -> KeyEvent.KEYCODE_BUTTON_Y
|
||||
BTN_WEST -> KeyEvent.KEYCODE_BUTTON_X
|
||||
BTN_TR -> KeyEvent.KEYCODE_BUTTON_R1
|
||||
BTN_TR2 -> KeyEvent.KEYCODE_BUTTON_R2
|
||||
BTN_START -> KeyEvent.KEYCODE_BUTTON_START
|
||||
BTN_THUMBR -> KeyEvent.KEYCODE_BUTTON_THUMBR
|
||||
BTN_MODE -> KeyEvent.KEYCODE_BUTTON_MODE
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
else -> KeyEvent.KEYCODE_UNKNOWN
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun normalize(event: KeyEvent): KeyEvent {
|
||||
val side = side(event.device)
|
||||
if (side < 0) return event
|
||||
val code = padKey(side, event.scanCode)
|
||||
if (code == KeyEvent.KEYCODE_UNKNOWN || code == event.keyCode) return event
|
||||
return KeyEvent(event.downTime, event.eventTime, event.action, code, event.repeatCount, event.metaState,
|
||||
event.deviceId, event.scanCode, event.flags, event.source)
|
||||
}
|
||||
|
||||
internal fun vrButton(side: Int, scanCode: Int): Int = when (side) {
|
||||
LEFT -> when (scanCode) {
|
||||
BTN_TL2 -> VR_TRIGGER
|
||||
BTN_TL, BTN_TR, BTN_TR2 -> VR_GRIP
|
||||
BTN_DPAD_DOWN, BTN_DPAD_RIGHT -> VR_A
|
||||
BTN_DPAD_UP, BTN_DPAD_LEFT, BTN_SELECT -> VR_B
|
||||
BTN_THUMBL -> VR_STICK
|
||||
BTN_Z -> VR_SYSTEM
|
||||
else -> 0
|
||||
}
|
||||
RIGHT -> when (scanCode) {
|
||||
BTN_TR2 -> VR_TRIGGER
|
||||
BTN_TR, BTN_TL, BTN_TL2 -> VR_GRIP
|
||||
BTN_SOUTH, BTN_EAST -> VR_A
|
||||
BTN_NORTH, BTN_WEST, BTN_START -> VR_B
|
||||
BTN_THUMBR -> VR_STICK
|
||||
BTN_MODE -> VR_SYSTEM
|
||||
else -> 0
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun vrRumbleDevice(slot: Int): Int? =
|
||||
if (slot == VR_RUMBLE_SLOT + LEFT || slot == VR_RUMBLE_SLOT + RIGHT) vrDevices[slot - VR_RUMBLE_SLOT] else null
|
||||
|
||||
@Synchronized
|
||||
internal fun setVrDevices(owner: Any, devices: IntArray) {
|
||||
vrOwner = owner
|
||||
vrDevices = devices
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
internal fun clearVrDevices(owner: Any) {
|
||||
if (vrOwner !== owner) return
|
||||
vrOwner = null
|
||||
vrDevices = NO_VR_DEVICES
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.glasses.Quaternion
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal class JoyConAccelCalibration {
|
||||
var bias = Vec3(0f, 0f, 0f)
|
||||
private set
|
||||
var scale = 1f
|
||||
private set
|
||||
var ready = false
|
||||
private set
|
||||
private val sums = HashMap<Int, DoubleArray>()
|
||||
|
||||
fun restore(saved: FloatArray?) {
|
||||
if (saved == null || saved.size != 4 || saved.any { it.isNaN() || it.isInfinite() }) return
|
||||
val b = Vec3(saved[0], saved[1], saved[2])
|
||||
if (!plausible(b, saved[3])) return
|
||||
bias = b
|
||||
scale = saved[3]
|
||||
ready = true
|
||||
}
|
||||
|
||||
fun saved(): FloatArray? = if (ready) floatArrayOf(bias.x, bias.y, bias.z, scale) else null
|
||||
|
||||
fun correct(raw: Vec3): Vec3 = if (ready) (raw - bias) * scale else raw
|
||||
|
||||
fun addResting(raw: Vec3): Boolean {
|
||||
val n = raw.length()
|
||||
if (n < 1e-3f) return false
|
||||
val key = bin(raw.x / n) * 9 + bin(raw.y / n) * 3 + bin(raw.z / n)
|
||||
val s = sums.getOrPut(key) { DoubleArray(4) }
|
||||
s[0] += raw.x.toDouble()
|
||||
s[1] += raw.y.toDouble()
|
||||
s[2] += raw.z.toDouble()
|
||||
s[3] += 1.0
|
||||
return s[3] % SOLVE_EVERY == 0.0 && solve()
|
||||
}
|
||||
|
||||
private fun solve(): Boolean {
|
||||
val points = sums.values.filter { it[3] >= MIN_SAMPLES }.map { doubleArrayOf(it[0] / it[3], it[1] / it[3], it[2] / it[3]) }
|
||||
if (points.size < MIN_BINS || smallestSpread(points) < MIN_SPREAD) return false
|
||||
val ata = Array(4) { DoubleArray(4) }
|
||||
val atb = DoubleArray(4)
|
||||
for (p in points) {
|
||||
val row = doubleArrayOf(2 * p[0], 2 * p[1], 2 * p[2], 1.0)
|
||||
val rhs = p[0] * p[0] + p[1] * p[1] + p[2] * p[2]
|
||||
for (i in 0..3) {
|
||||
atb[i] += row[i] * rhs
|
||||
for (j in 0..3) ata[i][j] += row[i] * row[j]
|
||||
}
|
||||
}
|
||||
val x = solve4(ata, atb) ?: return false
|
||||
val radius2 = x[3] + x[0] * x[0] + x[1] * x[1] + x[2] * x[2]
|
||||
if (radius2 <= 0) return false
|
||||
val b = Vec3(x[0].toFloat(), x[1].toFloat(), x[2].toFloat())
|
||||
val s = (GRAVITY / sqrt(radius2)).toFloat()
|
||||
if (!plausible(b, s)) return false
|
||||
bias = b
|
||||
scale = s
|
||||
ready = true
|
||||
return true
|
||||
}
|
||||
|
||||
private fun bin(v: Float) = (v * 1.5f).roundToInt().coerceIn(-1, 1) + 1
|
||||
|
||||
companion object {
|
||||
private const val GRAVITY = 9.80665
|
||||
private const val MIN_SAMPLES = 20.0
|
||||
private const val MIN_BINS = 5
|
||||
private const val MIN_SPREAD = 2.0
|
||||
private const val SOLVE_EVERY = 25.0
|
||||
private const val MAX_BIAS = 1.5f
|
||||
private const val MAX_SCALE_ERROR = 0.1f
|
||||
|
||||
private fun plausible(b: Vec3, s: Float) = b.length() <= MAX_BIAS && abs(s - 1f) <= MAX_SCALE_ERROR
|
||||
|
||||
private fun smallestSpread(points: List<DoubleArray>): Double {
|
||||
val mean = DoubleArray(3) { i -> points.sumOf { it[i] } / points.size }
|
||||
val c = Array(3) { DoubleArray(3) }
|
||||
for (p in points) for (i in 0..2) for (j in 0..2) c[i][j] += (p[i] - mean[i]) * (p[j] - mean[j])
|
||||
return sqrt(maxOf(smallestEigenvalue(c), 0.0))
|
||||
}
|
||||
|
||||
private fun smallestEigenvalue(m: Array<DoubleArray>): Double {
|
||||
val a = Array(3) { m[it].copyOf() }
|
||||
repeat(50) {
|
||||
var p = 0
|
||||
var q = 1
|
||||
for ((i, j) in listOf(0 to 2, 1 to 2)) if (abs(a[i][j]) > abs(a[p][q])) { p = i; q = j }
|
||||
if (abs(a[p][q]) < 1e-12) return minOf(a[0][0], a[1][1], a[2][2])
|
||||
val theta = (a[q][q] - a[p][p]) / (2 * a[p][q])
|
||||
val t = (if (theta >= 0) 1.0 else -1.0) / (abs(theta) + sqrt(theta * theta + 1))
|
||||
val c = 1 / sqrt(t * t + 1)
|
||||
val s = t * c
|
||||
for (k in 0..2) {
|
||||
val kp = a[k][p]
|
||||
val kq = a[k][q]
|
||||
a[k][p] = c * kp - s * kq
|
||||
a[k][q] = s * kp + c * kq
|
||||
}
|
||||
for (k in 0..2) {
|
||||
val pk = a[p][k]
|
||||
val qk = a[q][k]
|
||||
a[p][k] = c * pk - s * qk
|
||||
a[q][k] = s * pk + c * qk
|
||||
}
|
||||
}
|
||||
return minOf(a[0][0], a[1][1], a[2][2])
|
||||
}
|
||||
|
||||
private fun solve4(a: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
|
||||
val m = Array(4) { i -> DoubleArray(5) { j -> if (j < 4) a[i][j] else b[i] } }
|
||||
for (col in 0..3) {
|
||||
val pivot = (col..3).maxBy { abs(m[it][col]) }
|
||||
if (abs(m[pivot][col]) < 1e-9) return null
|
||||
val swap = m[col]
|
||||
m[col] = m[pivot]
|
||||
m[pivot] = swap
|
||||
for (r in 0..3) {
|
||||
if (r == col) continue
|
||||
val f = m[r][col] / m[col][col]
|
||||
for (k in col..4) m[r][k] -= f * m[col][k]
|
||||
}
|
||||
}
|
||||
return DoubleArray(4) { m[it][4] / m[it][it] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class JoyConMotion {
|
||||
var velocity = Vec3(0f, 0f, 0f)
|
||||
private set
|
||||
var displacement = Vec3(0f, 0f, 0f)
|
||||
private set
|
||||
var inertial = Vec3(0f, 0f, 0f)
|
||||
private set
|
||||
var resting = false
|
||||
private set
|
||||
private var gravity = Float.NaN
|
||||
private var average = Vec3(0f, 0f, 0f)
|
||||
private var calmFor = 0f
|
||||
private var stillFor = 0f
|
||||
private var movingFor = 0f
|
||||
|
||||
fun update(rotation: Quaternion, accel: Vec3, rate: Vec3, calibrated: Boolean, dt: Float) {
|
||||
if (dt <= 0f) return
|
||||
if (gravity.isNaN()) {
|
||||
gravity = accel.length()
|
||||
average = accel
|
||||
}
|
||||
if (calibrated) gravity = GRAVITY
|
||||
average += (accel - average) * (dt / AVERAGE_SECONDS).coerceAtMost(1f)
|
||||
val world = rotation.rotate(accel)
|
||||
val linear = Vec3(world.x, world.y - gravity, world.z)
|
||||
inertial = inertial * exp(-dt / INERTIAL_SECONDS) + linear * dt
|
||||
val calm = rate.length() < CALM_RATE && (accel - average).length() < CALM_ACCEL
|
||||
calmFor = if (calm) calmFor + dt else 0f
|
||||
val still = rate.length() < CALM_RATE &&
|
||||
(linear.length() < STILL_ACCEL || calmFor >= if (calibrated) CALIBRATED_CALM_SECONDS else UNCALIBRATED_CALM_SECONDS)
|
||||
stillFor = if (still) stillFor + dt else 0f
|
||||
resting = calmFor >= RESTING_SECONDS && rate.length() < RESTING_RATE
|
||||
if (stillFor >= STILL_SECONDS) {
|
||||
movingFor = 0f
|
||||
velocity *= exp(-dt / SETTLE_SECONDS)
|
||||
if (!calibrated) gravity += (accel.length() - gravity) * (dt / GRAVITY_SECONDS).coerceAtMost(1f)
|
||||
} else {
|
||||
movingFor += dt
|
||||
val leak = if (movingFor < FREE_SECONDS) LEAK_SECONDS else LONG_MOVE_LEAK_SECONDS
|
||||
velocity = velocity * exp(-dt / leak) + linear * dt
|
||||
}
|
||||
displacement += velocity * dt
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GRAVITY = 9.80665f
|
||||
private const val AVERAGE_SECONDS = 0.1f
|
||||
private const val CALM_RATE = 0.2f
|
||||
private const val CALM_ACCEL = 0.25f
|
||||
private const val STILL_ACCEL = 0.12f
|
||||
private const val STILL_SECONDS = 0.2f
|
||||
private const val UNCALIBRATED_CALM_SECONDS = 0.5f
|
||||
private const val CALIBRATED_CALM_SECONDS = 2f
|
||||
private const val SETTLE_SECONDS = 0.05f
|
||||
private const val LEAK_SECONDS = 20f
|
||||
private const val FREE_SECONDS = 1f
|
||||
private const val LONG_MOVE_LEAK_SECONDS = 0.2f
|
||||
private const val GRAVITY_SECONDS = 0.5f
|
||||
private const val INERTIAL_SECONDS = 30f
|
||||
private const val RESTING_SECONDS = 0.3f
|
||||
private const val RESTING_RATE = 0.05f
|
||||
}
|
||||
}
|
||||
|
||||
internal object JoyConCalibrations {
|
||||
fun load(context: Context, descriptor: String?): FloatArray? {
|
||||
if (descriptor.isNullOrEmpty()) return null
|
||||
val text = ControllerPrefs.prefs(context).getString(KEY + descriptor, null) ?: return null
|
||||
return text.split(',').mapNotNull { it.toFloatOrNull() }.toFloatArray().takeIf { it.size == 4 }
|
||||
}
|
||||
|
||||
fun save(context: Context, descriptor: String?, values: FloatArray?) {
|
||||
if (descriptor.isNullOrEmpty() || values == null) return
|
||||
ControllerPrefs.prefs(context).edit().putString(KEY + descriptor, values.joinToString(",")).apply()
|
||||
}
|
||||
|
||||
private const val KEY = "joyConAccel."
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import com.droiddeck.launcher.glasses.Quaternion
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import kotlin.math.abs
|
||||
|
||||
internal class JoyConOrientation {
|
||||
var rotation = Quaternion.IDENTITY
|
||||
private set
|
||||
var rate = Vec3(0f, 0f, 0f)
|
||||
private set
|
||||
var ready = false
|
||||
private set
|
||||
private var bias = Vec3(0f, 0f, 0f)
|
||||
private var stillFor = 0f
|
||||
|
||||
fun update(accelKernel: FloatArray, gyroKernel: FloatArray, dt: Float) = update(axes(accelKernel), axes(gyroKernel), dt)
|
||||
|
||||
fun update(accel: Vec3, gyro: Vec3, dt: Float) {
|
||||
val norm = accel.length()
|
||||
val gravityValid = abs(norm - GRAVITY) < GRAVITY_TOLERANCE
|
||||
if (!ready) {
|
||||
if (!gravityValid) return
|
||||
val up = accel.normalized()
|
||||
val axis = up.cross(WORLD_UP)
|
||||
rotation = if (up.y < -0.9999f) Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat())
|
||||
else Quaternion(1f + up.y, axis.x, axis.y, axis.z).normalized()
|
||||
rate = Vec3(0f, 0f, 0f)
|
||||
ready = true
|
||||
return
|
||||
}
|
||||
if (dt <= 0f) return
|
||||
val still = gravityValid && abs(norm - GRAVITY) < STILL_ACCEL && (gyro - bias).length() < STILL_RATE
|
||||
stillFor = if (still) stillFor + dt else 0f
|
||||
if (stillFor >= STILL_SECONDS) {
|
||||
val alpha = (dt / BIAS_SECONDS).coerceAtMost(1f)
|
||||
bias = bias * (1f - alpha) + gyro * alpha
|
||||
}
|
||||
rate = gyro - bias
|
||||
var corrected = rate
|
||||
if (gravityValid && rate.length() < MOVING_RATE) {
|
||||
val predictedUp = rotation.conjugate().rotate(WORLD_UP)
|
||||
corrected += accel.normalized().cross(predictedUp) * (GAIN * (1f - abs(norm - GRAVITY) / GRAVITY_TOLERANCE))
|
||||
}
|
||||
rotation = rotation.integrateBodyRate(corrected, dt).normalized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val WORLD_UP = Vec3(0f, 1f, 0f)
|
||||
private const val GRAVITY = 9.80665f
|
||||
private const val GRAVITY_TOLERANCE = 1.0f
|
||||
private const val STILL_ACCEL = 0.5f
|
||||
private const val STILL_RATE = 0.05f
|
||||
private const val STILL_SECONDS = 0.3f
|
||||
private const val BIAS_SECONDS = 2f
|
||||
private const val GAIN = 1.0f
|
||||
private const val MOVING_RATE = 1.0f
|
||||
|
||||
fun axes(v: FloatArray) = Vec3(-v[1], v[2], -v[0])
|
||||
}
|
||||
}
|
||||
|
||||
internal class JoyConReports {
|
||||
class Sample(val accel: FloatArray, val gyro: FloatArray, val dt: Float)
|
||||
|
||||
private val pending = ArrayList<FloatArray>(4)
|
||||
private var pendingAt = 0L
|
||||
private var previousFirst: FloatArray? = null
|
||||
private var previousLast: FloatArray? = null
|
||||
private var previousSample: FloatArray? = null
|
||||
private var previousAt = 0L
|
||||
private var order = NEWEST_FIRST_START
|
||||
|
||||
val size: Int get() = pending.size
|
||||
|
||||
fun add(accel: FloatArray, gyro: FloatArray, arrivalNs: Long) {
|
||||
if (pending.isEmpty()) pendingAt = arrivalNs
|
||||
pending += floatArrayOf(accel[0], accel[1], accel[2], gyro[0], gyro[1], gyro[2])
|
||||
}
|
||||
|
||||
fun flush(): List<Sample> {
|
||||
if (pending.isEmpty()) return emptyList()
|
||||
val first = pending.first()
|
||||
val last = pending.last()
|
||||
if (pending.size > 1) {
|
||||
if (previousFirst?.contentEquals(last) == true) order = (order + 1).coerceAtMost(ORDER_LIMIT)
|
||||
else if (previousLast?.contentEquals(first) == true) order = (order - 1).coerceAtLeast(-ORDER_LIMIT)
|
||||
}
|
||||
val chronological = if (order >= 0) pending.asReversed() else pending
|
||||
val fresh = ArrayList<FloatArray>(chronological.size)
|
||||
var before = previousSample
|
||||
for (sample in chronological) {
|
||||
if (before?.contentEquals(sample) != true) fresh += sample
|
||||
before = sample
|
||||
}
|
||||
val used = fresh.ifEmpty { listOf(chronological.last()) }
|
||||
val dt = if (previousAt == 0L) 0f else ((pendingAt - previousAt).coerceAtLeast(0L) * 1e-9f / used.size).coerceAtMost(MAX_STEP_S)
|
||||
val out = used.map { Sample(it.copyOfRange(0, 3), it.copyOfRange(3, 6), dt) }
|
||||
previousFirst = first
|
||||
previousLast = last
|
||||
previousSample = chronological.last()
|
||||
previousAt = pendingAt
|
||||
pending.clear()
|
||||
return out
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SAMPLES_PER_REPORT = 3
|
||||
private const val NEWEST_FIRST_START = 2
|
||||
private const val ORDER_LIMIT = 20
|
||||
private const val MAX_STEP_S = 0.05f
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.glasses.Quaternion
|
||||
import com.droiddeck.launcher.glasses.Vec3
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.MappedByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
internal class JoyConShm private constructor(
|
||||
private val file: RandomAccessFile,
|
||||
private val map: MappedByteBuffer,
|
||||
private val fence: () -> Unit,
|
||||
) {
|
||||
class Hand(
|
||||
val connected: Boolean,
|
||||
val charging: Boolean = false,
|
||||
val buttons: Int = 0,
|
||||
val rotation: Quaternion = Quaternion.IDENTITY,
|
||||
val rate: Vec3 = Vec3(0f, 0f, 0f),
|
||||
val displacement: Vec3 = Vec3(0f, 0f, 0f),
|
||||
val velocity: Vec3 = Vec3(0f, 0f, 0f),
|
||||
val inertial: Vec3 = Vec3(0f, 0f, 0f),
|
||||
val stickX: Float = 0f,
|
||||
val stickY: Float = 0f,
|
||||
val battery: Float = -1f,
|
||||
val recenter: Int = 0,
|
||||
val timeNs: Long = 0L,
|
||||
)
|
||||
|
||||
private var seq = map.getInt(SEQ).let { if (it and 1 != 0) it + 1 else it }
|
||||
|
||||
init {
|
||||
begin()
|
||||
map.putInt(MAGIC_AT, MAGIC)
|
||||
map.putInt(VERSION_AT, VERSION)
|
||||
for (at in HANDS until SIZE step 4) map.putInt(at, 0)
|
||||
end()
|
||||
}
|
||||
|
||||
fun write(side: Int, hand: Hand) {
|
||||
val at = HANDS + side * HAND_SIZE
|
||||
begin()
|
||||
map.putInt(at, (if (hand.connected) CONNECTED else 0) or (if (hand.charging) CHARGING else 0))
|
||||
map.putInt(at + 4, hand.buttons)
|
||||
map.putFloat(at + 8, hand.rotation.w)
|
||||
map.putFloat(at + 12, hand.rotation.x)
|
||||
map.putFloat(at + 16, hand.rotation.y)
|
||||
map.putFloat(at + 20, hand.rotation.z)
|
||||
map.putFloat(at + 24, hand.rate.x)
|
||||
map.putFloat(at + 28, hand.rate.y)
|
||||
map.putFloat(at + 32, hand.rate.z)
|
||||
map.putFloat(at + 36, hand.stickX)
|
||||
map.putFloat(at + 40, hand.stickY)
|
||||
map.putFloat(at + 44, hand.battery)
|
||||
map.putInt(at + 48, hand.recenter)
|
||||
map.putLong(at + 56, hand.timeNs)
|
||||
map.putFloat(at + 64, hand.displacement.x)
|
||||
map.putFloat(at + 68, hand.displacement.y)
|
||||
map.putFloat(at + 72, hand.displacement.z)
|
||||
map.putFloat(at + 76, hand.velocity.x)
|
||||
map.putFloat(at + 80, hand.velocity.y)
|
||||
map.putFloat(at + 84, hand.velocity.z)
|
||||
map.putFloat(at + 88, hand.inertial.x)
|
||||
map.putFloat(at + 92, hand.inertial.y)
|
||||
map.putFloat(at + 96, hand.inertial.z)
|
||||
map.putInt(at + 100, 0)
|
||||
end()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
write(JoyCon.LEFT, Hand(connected = false))
|
||||
write(JoyCon.RIGHT, Hand(connected = false))
|
||||
runCatching { file.close() }
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
map.putInt(SEQ, ++seq)
|
||||
fence()
|
||||
}
|
||||
|
||||
private fun end() {
|
||||
fence()
|
||||
map.putInt(SEQ, ++seq)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILE = "droiddeck-joycon"
|
||||
const val SIZE = 224
|
||||
private const val MAGIC = 0x4E434A44
|
||||
private const val VERSION = 3
|
||||
private const val MAGIC_AT = 0
|
||||
private const val VERSION_AT = 4
|
||||
private const val SEQ = 8
|
||||
private const val HANDS = 16
|
||||
private const val HAND_SIZE = 104
|
||||
private const val CONNECTED = 1
|
||||
private const val CHARGING = 2
|
||||
|
||||
fun open(path: File, fence: () -> Unit = FakeInputWriter::storeFence): JoyConShm? = runCatching {
|
||||
path.parentFile?.mkdirs()
|
||||
val file = RandomAccessFile(path, "rw")
|
||||
try {
|
||||
file.setLength(SIZE.toLong())
|
||||
val map = file.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong())
|
||||
map.order(ByteOrder.LITTLE_ENDIAN)
|
||||
JoyConShm(file, map, fence)
|
||||
} catch (e: Exception) {
|
||||
file.close()
|
||||
throw e
|
||||
}
|
||||
}.onFailure { Log.w("JoyConVr", "Joy-Con pose file unavailable: $path", it) }.getOrNull()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user