Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx d2a7723b1b fix: use the package affinity for secondary controls 2026-10-17 19:34:48 -04:00
xXJsonDeruloXx 7243228039 fix: host second-screen controls in a dedicated activity 2026-10-17 19:32:45 -04:00
MaxsTechReview 45513c90d6 fix: rename the VR recovery task affinity in variant builds (#1038) 2026-10-17 17:24:31 -04:00
MaxsTechReview f3f3ba1a8a fix: rename the VR recovery task affinity in variant builds
The PUBG, AnTuTu and Ludashi signing jobs stopped on
com.droiddeck.launcher.vr_recovery, the task affinity of the VR
recovery activity added with the VITURE XR controls, because the
package rename did not cover it.
2026-10-17 17:14:06 -04:00
MaxsTechReview 97f976ef6c feat: Epic Games Launcher for Linux (#928)
* feat: Epic Games Launcher for Linux

Install Epic's official Linux launcher (EGLv2) from Epic's CDN with Valve's
Steam Runtime 4 and run it under FEX, signed in with the linked Epic account.
Epic games launch through a hidden launcher instance so they get the
launcher's own arguments, with the exchange-code launch as the fallback, and
DroidDeck installs show up in the launcher as installed.

In the launcher, touch scrolls like a phone app and the gamepad drives the UI:
D-pad and left stick move the selection, A opens, B goes back, the right stick
and triggers scroll proportionally, X opens the options menu, Y the keyboard
and LB/RB switch tabs.

* feat: Epic Games Launcher for Linux

Install Epic's official Linux launcher (EGLv2) from Epic's CDN with Valve's
Steam Runtime 4 and run it under FEX, signed in with the linked Epic account.
Epic games launch through a hidden launcher instance so they get the
launcher's own arguments, with the exchange-code launch as the fallback, and
DroidDeck installs show up in the launcher as installed.

In the launcher, touch scrolls like a phone app and the gamepad drives the UI:
D-pad and left stick move the selection, A opens, B goes back, the right stick
and triggers scroll proportionally, X opens the options menu, Y the keyboard
and LB/RB switch tabs.

* Route Epic games through Steam, import launcher installs, offer the second library

- Every Epic launch goes Steam -> Epic Games Launcher -> game; Play inside the open launcher hands the game to Steam
- Games installed from inside the launcher get a Games-tab entry and launch through Steam; the launcher installs to the last Stores target
- Stores install picker offers the Steam second library, with side-by-side choices
- uruntime xattr-only failures no longer fail the launcher unpack (app-extract)

* Hand launcher Play and Steam links to Steam, wait for FEX on first run

Play on a game the launcher just installed is matched to its install
record and handed to Steam. Steam games listed in the launcher open
steam://rungameid links, which now start the game in a Steam session.
Opening the launcher before Steam has downloaded FEX asks Steam for it
and starts Steam instead of failing with no-fex.

* Address review: FEX check, play-in-Steam action, touch route thread, scroll scale

fexReady needs a FEX binary in Steam's FEX tool, not just its folder.
The play-in-Steam action stays until the link is handled, so Run Steam
at startup can't start a session first. The pad touch route reads the
mouse mode without side effects off the main thread and posts the pad
update there. Vertical scroll uses the output height. A status-2 unpack
counts only when AppRun is there. The xdg-open command is tested
end to end for Steam links.

* Open the launcher through Steam, recover dropped shortcuts, sign out of both

The Epic Games Launcher now runs as its own Steam shortcut, so opening
it starts Steam, which opens the launcher, and Play inside it goes to
the running Steam.

Steam dropped a running shortcut from its library when the game exited
after a sleep/wake replay or a server re-logon, and launching it then
waited forever at ShowInterstitials. login1 only replays sleep/wake
when Steam is asleep. The session restarts Steam when a requested
shortcut is missing, and when a game exits with its shortcut gone and
nothing else runs.

Resuming after a long pause no longer reports failure while traced
processes are still waking, so "Steam is paused" goes away on the first
tap.

Signing out of the launcher signs DroidDeck's Epic store out, and
signing out in DroidDeck restarts the launcher signed out and clears
the Epic web cookies.

* Address review: one session-switch poll, Epic setup can't block a session

A new switch confirmation replaces the pending poll instead of adding a
second one that could start the session again. Epic library sync and
install-folder setup at session start are each guarded, so a bad
preference no longer stops the guest from launching.

* Uninstall the Epic Games Launcher from the Games tab

An Uninstall button next to Launch asks first, closes a running
session, and removes the launcher and its runtime. The launcher's
sign-in and install records stay, so its games keep their Games-tab
entries and a reinstall comes back signed in. Its Steam shortcut goes
away on the next Steam start.

* Address review: uninstall only after the session has ended

The launcher's files are removed only once the session is not running
and its phase has ended; a shutdown still in progress after the wait
reports failure instead. The confirmation reads the session state from
the front-end state.

* Find FEX that Steam installed on the second library

Steam's tools on a second library live in DroidDeck's private copy,
which the session binds over the library. The launcher's FEX check
only looked on the shared storage, so with FEX there it always asked
Steam for FEX and only Steam opened. It now checks the private copy
too.

* Start games without Steam, per-game Settings menu with Wine sync

"Start without Steam" now really leaves the Steam client out: the game
runs in its own Proton/FEX through droiddeck-proton-run in a run
session, and the Epic Games Launcher opens on its own with its games
playing in it. Run sessions for these games are not pinned to the big
cores and get DirectAudio and the added-games list like Steam.

The Games tab card is now "Settings", titled "<game> Settings": Start
without Steam, FEX preset, Wine sync (ntsync, fsync, esync, wineserver
or the app default) and Default, which puts everything changed for the
game back to the app's settings. The app-wide Wine sync choice is shown
in the Steam settings Session tab.

* Address review: uninstall guard, routed games, shortcut matching

Opening or reinstalling the Epic Games Launcher waits while it is being
uninstalled. Added games that are Steam installs keep their Start
without Steam setting under their Steam app id and start as that Steam
game. Live shortcut lookups wait for Steam's library to load and match
a same-name shortcut by its executable, so no duplicate is added. The
session treats the open launcher as itself even when it could not tell
its Steam app id.

* Address review: drop a queued launcher switch when it is uninstalled

A confirmed switch to the Epic Games Launcher that is still waiting for
the old session to end is cancelled when the launcher is uninstalled,
and never starts while the uninstall runs. The launcher id test sets
its environment for its own runs only.
2026-10-17 14:40:39 -04:00
+13 83eda433c8 Add VITURE XR controls, head tracking, and SteamVR (#732)
* Add VITURE glasses support: hardware control, 3D, head tracking

Glasses get their own controller over the MCU (permission, 120 Hz default, brightness, volume, sunblock, 2D/3D) and the game moves onto them when plugged in. The IMU drives Cardboard head tracking and the SteamVR driver. 3D falls back to one eye's view on phones that can't send the 3840x1080 signal. Options open from a glasses button in game and on the Steam page.

* Drop the glasses settings like Steam's: Play-style button, Settings lip, tabs

* Hang the glasses Settings lip over the top of the button

* Space glasses MCU commands so the IMU rate sticks, fit fixed resolutions to the glasses

* Keep Steam alive with SteamVR registered, run SteamVR at the glasses' rate, keep the glasses lip on top

* Fit VR games on the glasses: full view, seated origin at the head, pad hands reach with the d-pad

* Let the pad hands reach a metre out so buttons with deep colliders press

* Show every VR frame in order at 120 fps: wait for SteamVR's GPU work, present only finished copies, skip the grace on idle shared counters

* Glasses: SteamVR starts and stops with them, closer view with a field of view setting, Recenter on the pad

* Glasses: SteamVR field of view defaults to 90°

* VR pad hands: sticks move and aim the hands, left stick click switches the sticks to the game, d-pad pulls a held hand

* Glasses: Play SteamVR beside the glasses button instead of starting SteamVR with them, pad hands drawn as Index controllers and off while other VR controllers are connected, glasses sheet tabs sized like Steam's

* Track glasses mode, refresh-aware glasses window, align Play SteamVR with Play Steam

* Fix 3D on the glasses: full-res SteamVR presenter, revert VR 3D at session end, coalesce vsync ticks, no session restart after end

* Address review: glasses output after installs and session end, VR revert retry, display fallback, presenter swapchain retry, agent hex checks, drawer bumpers, glasses translations, docs

* Harden VITURE stereo negotiation and output refresh tracking

* Localize stereo recovery feedback and record Astra reconnect tests

* Improve test failure diagnostics and refresh Astra firmware findings

* Handle VITURE docks separately and reject truncated USB frames

* Glasses: remember a 3D fallback per model, docks as display only, VR timing fixes

- A 3D attempt that ends at the 640x480 fallback is remembered for that glasses
  model, so Play SteamVR no longer blacks out the glasses after every restart;
  Retry clears it and "3D for VR games" stays reachable
- A VITURE Mobile Dock on its own no longer counts as glasses; through a dock
  Play SteamVR is hidden and the sheet shows the output and why there is no control
- HDR is probed on the glasses when the session starts there
- Glasses frames with a mismatched checksum are marked in the status frame log
- SteamVR layer and driver vblank counters stay monotonic across refresh changes;
  one-eye render scale applies from activation and on mono/3D switches
- Compositor binds announce the refresh clients were last told; the presenter
  retries a failed first swapchain
- Dead strings and helpers removed; docs updated

* Glasses: drop USB frames with a bad checksum

* Glasses: count frames dropped for a bad checksum

* Feat/viture xr (#907)

* Add RayNeo Air and GT support to the XR glasses pipeline

* Add RayNeo Air and GT support to the XR glasses pipeline

* Checkpoint glasses tracking, calibration, and SteamVR launch integration

* Checkpoint glasses tracking, calibration, and SteamVR launch integration

* Add XREAL 1S support and correct GT and Pro 2 tracking

* Add XREAL 1S support and correct GT and Pro 2 tracking

* Fix mirrored glasses tracking and recover SteamVR after device loss

* Fix mirrored glasses tracking and recover SteamVR after device loss

* Document confirmed XREAL 1S brightness control

* Document confirmed XREAL 1S brightness control

* Fix SteamVR glasses defaults and rendering controls

* Fix SteamVR glasses defaults and rendering controls

* chore: keep only the 3 newest session log folders (#852)

* chore: add agent comment rules (#748)

Co-authored-by: Kurt Himebauch <[email protected]>

* Fix SteamVR polling, VITURE checksums, and fence tracking

Keep manual SteamVR launches polled, discard corrupt VITURE packets, and retire Vulkan fences out of order without blocking submissions at a fixed capacity. Preserve the contiguous completion watermark used by the presenter.

Fix optional Windows launcher configuration and audited helper staging. Add Android, Windows, and native tracker regression coverage.

* Fix SteamVR polling, VITURE checksums, and fence tracking

Keep manual SteamVR launches polled, discard corrupt VITURE packets, and retire Vulkan fences out of order without blocking submissions at a fixed capacity. Preserve the contiguous completion watermark used by the presenter.

Fix optional Windows launcher configuration and audited helper staging. Add Android, Windows, and native tracker regression coverage.

* fix: wireless ADB page names the child-process switch the way Android shows it (#848)

* l10n: refresh stale and untranslated strings in es, fr, ko, ru (#850)

* l10n: refresh stale and untranslated strings in es, fr, ko, ru

- mode_added_art_hint: now mentions Steam then SteamGridDB (all four locales).
- wincomp_needs_installer / wincomp_installers_note: es and ko still described
  the old "coming soon" behaviour; align with the current English text.
- summary_remove_game: was still English in all four locales.

Machine-assisted translation; native-speaker review is welcome.

* l10n(es): say registro, not grabación, for the missing installer record

---------

Co-authored-by: ovurrsl <[email protected]>

* fix: a failed cloud save recovery no longer ends the app (#846)

The cloud-recovery thread started at every app start had nothing to
catch an error, so one went to CrashHandler, which halts the process
with status 2. In unit tests the thread can outlive the test that
started it, fail on the torn-down preferences, and take the whole test
run down. The error is now logged and the thread ends.

Co-authored-by: Kirk <[email protected]>

* fix: an interrupted Linux runtime download resumes instead of starting again (#847)

* fix: an interrupted Linux runtime download resumes instead of starting again

installOnce() asked the downloader to resume but always deleted the archive in its finally block, so the next attempt began at zero. The archive is now named after the release version and checksum, a failed download keeps what it fetched, and archives of other releases (and the old fixed name) are removed. A checksum or extraction failure still deletes the file.

* test: Downloader resumes a cut-off download from the bytes it kept

Starts a socket server that promises the whole file, hangs up part way and then answers a Range request, and checks that the second downloadFile call resumes with the right offset and ends with identical bytes.

* fix: a resumed download discards a 206 that does not start where the kept bytes end

Downloader appended every 206 response to the partial file without
looking at Content-Range. A server or proxy that answers a Range request
with a different offset would leave a corrupt file. Check that the range
starts at the kept length; otherwise delete the partial file and start
again without a Range header.

Adds a test with a server that answers the Range request from offset 0.

---------

Co-authored-by: ovurrsl <[email protected]>

* l10n: German, French, Spanish, Italian, Portuguese and Russian for the zRAM, reset-settings and store free-space strings (#849)

* l10n: German, French, Spanish, Italian, Portuguese and Russian for the zRAM, reset-settings and store free-space strings

Adds the strings values-de, values-it and values-pt were missing (14 each) and the six that values-fr, values-es and values-ru were missing. Machine-assisted drafts that follow each file's existing register and terms; native review is welcome.

* l10n: correct missing active swap translations

---------

Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>

* fix: droiddeckctl reaches variant-package builds (recover #842) (#854)

fix: droiddeckctl reaches builds shipped under a variant package

The provider authority and the start Activity's component were fixed to
com.droiddeck.launcher, so droiddeckctl could not talk to the antutu,
ludashi or pubg builds from tools/release/variants.txt. DROIDDECK_PACKAGE
now picks the package; the Activity's class name stays the same in every
variant.

Co-authored-by: angelrenefm <[email protected]>

* feat: droiddeckctl reports on-screen pacing (recover #843) (#855)

feat: droiddeckctl pacing reports the frames that reached the screen

Reads SurfaceFlinger's timestats for the game's layer (or the session's
SurfaceView) over a capture: frames on screen, fps on screen, the
present-to-present histogram and how many intervals were off the usual
cadence. Records split by a refresh-rate change are summed. A game's own fps
counter counts frames handed over, so it can't show dropped or doubled frames.

Co-authored-by: angelrenefm <[email protected]>

* fix: Steam can restart more than twice in a session (#754)

* fix: Steam can restart more than twice in a session

Every exit 42 counted toward the cap that stops an update which will
not install, and the count was never cleared, so the third restart of
a session ended it however far apart they were. A client that ran for
a minute before asking is not that loop: the count starts again.

* fix: measure the client's run time as it exits

The time spent collecting logs and checking FEX after the client exits
counted toward its run, so a slow cleanup could make a quick exit 42
look like a long one and clear the count.

---------

Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>

* fix: the front end keeps its page when the Fold is folded or unfolded (#832)

* fix: the front end keeps its page when the Fold is folded or unfolded

MainActivity, FileManagerActivity, FilePickerActivity and StoreLoginActivity did not declare the screen-size configuration changes, so folding or unfolding a foldable recreated them and dropped their in-memory state. They now handle orientation, screenSize, screenLayout, smallestScreenSize and density themselves, like SessionActivity already does. uiMode is left out on purpose.

* fix: store login keeps rebuilding its toolbar when the display density changes

StoreLoginActivity builds its toolbar in code with dp-to-pixel values taken at creation time and has no onConfigurationChanged. It no longer declares density in configChanges, so a density change recreates it as before this PR. Size and layout changes are still handled in place.

---------

Co-authored-by: ovurrsl <[email protected]>

* Release pointer buttons without replaying stale motion (#803)

* Fix cursor warping on native touch down

* Preserve input cleanup without replaying stale pointer motion

---------

Co-authored-by: siyuan.qsy <[email protected]>

* l10n: translate the strings added since #284 into zh-CN, zh-HK, zh-TW and ja (#539)

* l10n: translate the 184 strings added since #284 (zh-CN/HK/TW, ja)

The stores, cloud saves, Windows components, Steam touch, desktop
removal and background-downloads strings landed after #284's
translation snapshot, so every one of them fell back to English.
Translate all 184 into Simplified Chinese, Traditional Chinese
(Taiwan and Hong Kong) and Japanese, keeping each locale file's own
vocabulary (工作階段/會話, 解除安裝/卸載, 元件/組件, 設定).

Also fix four Hong Kong strings that kept the simplified 着色器.

* l10n(zh): fix stale and ambiguous strings found in review

Severe: summary_remove_game was untranslated English in all three
files; mode_added_art_hint, wincomp_installers_note and
wincomp_needs_installer still described the pre-#480/#492 English.

Also: distinguish the Setup wizard from Android system settings in
zh-HK/zh-TW (初始設定 / 系統設定 / Android 設定), unify library as
收藏庫, component as 組件 in zh-HK, session as 會話 in zh-HK, account
as 帳戶 in zh-HK, Trending as 趋势/趨勢, Forget as 清除, reword the
re-applied-at-launch, action-failed and already-in-Games strings, and
play as 執行/開啟遊戲 in zh-TW/zh-HK.

* l10n(ja): fix stale and ambiguous strings found in review

---------

Co-authored-by: Dantalian <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>

* Rebuild and verify Windows VR runtime components

* Rebuild and verify Windows VR runtime components

* Add top, bottom, and centered picture positions for Steam sessions (#871)

* fix: dependency scan ignores folders above the game folder (#859)

* DroidDeck-Proton: get releases from the Droid-Deck org repo

DroidDeck-Proton is now built and released on Droid-Deck/DroidDeck-Proton
(11-9.1 is Latest there), so the Components page, the update check and the
guest installer read releases from the org repo instead of The412Banner's.

* feat: low-latency controller and touch toggles in the session drawer (#808)

* fix: SteamGridDB lookups no longer stick as "no match" after a failure; add a search to the art chooser (#876)

* feat: Steam home wall fills from the signed-in library instead of store picks (#878)

* test: cover GPU family and support classification (#880)

* Fix local builds on Linux failing after the first Comet build (#866)

* feat: sort the store library and installed tabs, and switch between card and list views (#798)

* feat: three-finger tap toggles Steam's on-screen keyboard in game mode (#782)

* fix: keep Quest-patched Turnip as the auto default (#853)

* Fix deploy_local.sh and droiddeckctl not finding a phone connected over wireless debugging (#864)

* fix: keep proot sessions alive when the kernel loses fork events (#457)

* fix(quest): correct desktop and Steam display corruption across sessions (#733)

fix(quest): stabilize desktop and Steam layer presentation

Keep Quest SDR scenes and the black backdrop off the Android Vulkan swapchain regardless of session order or HDR preferences. Only report the first frame after client content is submitted.

Add backdrop lifecycle and first-frame regression coverage. Record the user's successful Quest 3 Test 2 result.

Co-authored-by: xXJsonDeruloXx <[email protected]>

* fix: keep SteamVR syscall hooks and report Steam crashes (#905)

* fix: preserve SteamVR syscall hooks

* fix: report Steam crashes through gamescope

* fix: honor glasses pacing and resolution in SteamVR launches

---------

Co-authored-by: connor g <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: Yassine Gherbi <[email protected]>
Co-authored-by: Resul Övür <[email protected]>
Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: angelrenefm <[email protected]>
Co-authored-by: asdf17128 <[email protected]>
Co-authored-by: siyuan.qsy <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: FX1922 <[email protected]>
Co-authored-by: Muaz Kadan <[email protected]>
Co-authored-by: kingokksa <[email protected]>
Co-authored-by: Jamie Schenker <[email protected]>
Co-authored-by: Jagger15 <[email protected]>

* Glasses: keep a pending 2D restore and the SteamVR recovery offer, show Play SteamVR only with a picture

* Translate the glasses calibration, XREAL and SteamVR recovery strings

* SteamVR recovery: give a finished teardown time to collect logs before restarting

* Credit the projects the XR glasses support builds on

* Joy-Cons: SteamVR hands from their gyro while SteamVR runs, one combined pad otherwise

* Headsets: settings badge on Play SteamVR instead of a top-right button, both shown only once connected

* Hand tracking: camera-tracked Joy-Cons and bare hands in SteamVR, drawn as white gloves

- Joy-Con movement from the accelerometer (report reordering, per-Joy-Con calibration, zero-velocity updates)
- MediaPipe pose and hand landmarks from the device camera, placement check, per-hand Kalman filter with the Joy-Con
- No controller connected: bare camera hands with Quest-style gestures, 1€ filtered, rendered as a white glove model built from a distance field
- SteamVR safe mode cleared before each start so one crash no longer blocks the driver

---------

Co-authored-by: MaxsTechReview <[email protected]>
Co-authored-by: informalTechCode <[email protected]>
Co-authored-by: connor g <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: Yassine Gherbi <[email protected]>
Co-authored-by: Resul Övür <[email protected]>
Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: angelrenefm <[email protected]>
Co-authored-by: asdf17128 <[email protected]>
Co-authored-by: siyuan.qsy <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: FX1922 <[email protected]>
Co-authored-by: Muaz Kadan <[email protected]>
Co-authored-by: kingokksa <[email protected]>
Co-authored-by: Jamie Schenker <[email protected]>
Co-authored-by: Jagger15 <[email protected]>
2026-10-17 13:17:05 -04:00
kingokksa e95a4226eb l10n: translate the missing strings in every locale, and complete es-419 (#1013)
* l10n: translate the missing strings into zh-CN, zh-HK and zh-TW

* l10n: translate the missing strings into ja and ko

* l10n: translate the missing strings into it, pt, pt-PT, ru and tr

* l10n: translate the missing strings into nl, pl, sv, da, nb and fi

* l10n: translate the missing strings into ar, bg, cs and el

* l10n: translate the missing strings into hi, hu, in, ms and ro

* l10n: translate the missing strings into th, uk and vi

* l10n: complete the es-419 translation

* l10n: translate the missing strings into de, es and fr
2026-10-17 11:44:59 -04:00
FX1922 26ca374c47 feat: Adjust a game’s logo position and size in the artwork editors (#1004)
* Add an Adjust logo screen to move and resize a game's logo in Steam

* Report a failed write of the local logo position instead of throwing

* Record a Steam-side logo position change only once it has been taken over here
2026-10-17 10:02:22 -04:00
FX1922 64d78aa812 Feat/store game artwork (#976) 2026-10-17 01:19:02 -04:00
Resul Övür 522e755b37 feat: the session report lists the panel refresh modes, thermal state and hint-session support (#990) 2026-10-17 02:31:17 +00:00
Resul Övür 9c9486400b Launch an added game after a bounded wait for its Steam overview (#973) 2026-10-16 17:21:53 -04:00
Kurt Himebauch 62192203e9 feat: auto drivers update on app open and roll back if a new pair never starts a session (#972) 2026-10-16 16:17:44 -04:00
FX1922 d8df18df82 fix: Keep game art in step with Steam's grid folder; apply review fixes (#948) 2026-10-16 16:14:49 -04:00
Kurt Himebauch 953c11ed48 feat: boot movie settings for 30/60 fps, sound and custom imports (#969) 2026-10-16 15:07:46 -04:00
Kurt Himebauch c796c7cd48 Feat/gpu drivers redesign (#858) 2026-10-16 17:50:45 +00:00
kingokksa 89a0e4d25f feat: downloads resume where they stopped, and fall back to another source (#623) 2026-10-16 13:45:35 -04:00
Kurt Himebauch 59a5afa21d fix: reduce flickering on thor and a7xx: match the A740 TP UBWC flag hint to the system driver so Android UI stops flickering (#963) 2026-10-16 13:37:28 -04:00
TideGear 66529d9026 fix: hold XInput rumble until the game changes it, instead of stopping after a second (#938) 2026-10-16 17:30:21 +00:00
Kurt Himebauch abd89f9954 fix: session achievements page no longer sticks on Reading (#961) 2026-10-16 15:51:57 +00:00
Kurt Himebauch f7ffab1345 feat: merge SteamVR and Cardboard into one VR drawer tab (#959) 2026-10-16 14:59:34 +00:00
angelrenefm 8886304894 fix: the proot fast path finds unix sockets whose host path is longer than sun_path (#841)
* fix: the proot fast path finds unix sockets whose host path is longer than sun_path

When a socket's host path did not fit sun_path, bind() went to proot, which
bound it to a short temporary name the fast path cannot see. The fast path's
own connect, stat and unlink then answered ENOENT, so FEXServer's socket under
compatdata/<app>/fex-emu/Server could never be reached (#839, #579).

Name such a socket through its directory (/proc/self/fd/N/<name>) instead, so
it lives at its real host path for every process, and report the guest path
back from getsockname, getpeername and accept. equiv3.py's long-path section
binds, connects, stats, names and unlinks a socket there.

* fix: key long socket names per socket and reset their lock after fork

Review follow-up. A long name is now looked up by the socket's inode and the
name, separately for its own and its peer's address, so two sockets with the
same file name in different directories no longer mix up; a missing entry is
left as the kernel reports it. The lock is re-initialised in the child after
fork, and the copies use snprintf. equiv3.py binds a second socket with the
same name in a sibling directory and checks both names.
2026-10-16 10:05:12 -04:00
angelrenefm 0b8d55d434 fix: droiddeckctl refuses a DROIDDECK_PACKAGE that is not a package name (#896)
The value goes to the device shell inside `am start -n` and `content call --uri`.
Anything but a dotted Android package name is refused before adb runs.
2026-10-16 10:05:02 -04:00
angelrenefm 99381a8f3e fix: droiddeckctl pacing judges cadence per refresh-rate record and always turns timestats off (#894)
* fix: droiddeckctl pacing judges cadence per refresh-rate record and always turns timestats off

Follow-up to the CodeRabbit reviews of #843/#855:
- off-cadence is counted per layer record against that record's own cadence,
  allowing the neighbouring millisecond bucket (16.7 ms lands in 16 or 17);
- refreshRates comes from the chosen layer records only;
- records with no counted frames are skipped, so an empty game layer no longer
  hides the session's SurfaceView, and a capture with no frames exits NO_FRAMES;
- adb failures while enabling or dumping map to PACING_FAILED, and timestats
  is disabled in every case.

* fix: droiddeckctl pacing reports when timestats could not be turned off
2026-10-16 09:50:43 -04:00
Resul Övürandovurrsl a422698f92 Keep waiting for a shortcut's Steam overview instead of dropping its cold launch (#914)
fix: keep waiting for a shortcut's Steam overview instead of dropping its launch

The cold-launch gate waited once for 45 s and, on a timeout, held the
launch URL for good. A library that loads slower than that (or a client
still at the sign-in screen) left the requested game never started, with
nothing on screen. Retry the wait up to 40 times, 45 s each, and launch
as soon as the overview exists. The no-overview case still does not
forward the CGameID.

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:35 -04:00
Resul Övürandovurrsl dce7e7ece6 fix: the store sign-in page keeps its place when the display density changes (#886)
* fix: the store sign-in page keeps its place when the display density changes

StoreLoginActivity sized its toolbar and error panel in code with dp(), so it
was left out of the density change and a fold between two displays of
different density still recreated it and reloaded the sign-in page. Handle
density in the manifest and apply the dp sizes again in onConfigurationChanged.

Checked on the foldable emulator with the GOG sign-in open: wm density
420 -> 560 -> 320 does not relaunch the activity, the page stays, and the top
bar follows the new density.

* fix: re-apply the sign-in text sizes together with the other dp sizes

The title (16sp) and error text (15sp) were set in sp at creation, so after a density change they kept the old scale. Set them again in onConfigurationChanged.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:28 -04:00
Resul Övürandovurrsl e3fe63aee6 fix: the store sign-in WebView cannot read local files or content URIs (#890)
The sign-in pages are ordinary web pages. file access and content access
default to on below targetSdk 30, so switch both off explicitly.

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:21 -04:00
Resul Övürandovurrsl 22c85c0cb4 fix: the launcher does not offer its own display as a second screen (#889)
* fix: the launcher does not offer its own display as a second screen

MainActivity asked for the second-screen candidates with the default
display as the primary one. When the launcher is itself shown on another
display (DeX, an external monitor), that display was listed as a second
screen. Pass the Activity's own display id, as SessionActivity already does.

* fix: read the Activity display only on API 30 and newer

minSdk is 26 and Activity.getDisplay needs API 30. Use windowManager.defaultDisplay below that.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:14 -04:00
Resul Övürandovurrsl a548d381b8 fix: store background jobs log a failure instead of ending the app (#898)
* fix: store background jobs log a failure instead of ending the app

refresh, sign-out, uninstall and the EOS overlay cleanup ran on plain threads. An exception there reaches the crash handler, which ends the process. Run them through one helper that logs the failure; a failed uninstall also clears its row and calls onDone so the UI does not stay on uninstalling.

* ci: run the build again

The first run failed while installing packages (archive.debian.org returned 404 for libnsl-dev). Nothing in the change differs.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:06 -04:00
Resul Övürandovurrsl 9abb21e389 l10n: refresh outdated strings, one name for the child-process switch, pt-rPT gaps (#884)
l10n: refresh outdated strings and name the child-process switch the same way everywhere

- hu: ctrl_size_hint showed a literal "%%".
- es, fr, ru: game_env_reset, mode_controller_note and comp_no_proton still
  described older English text.
- fr: the x86_64 AppImage (through FEX) strings were missing the x86_64 case.
- fr: put the spaces back inside the guillemets of the switch name.
- All locales: gate_toggle_steps and phantom_fix_toggle now use the same
  switch name as wadb_restrict_on/off, which is the title Android 17 shows.
- pt-rPT: add the 14 strings it was missing (reset settings, zRAM, store
  free space), as a European Portuguese draft.

Machine-assisted; native review is welcome.

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:49:58 -04:00
Resul Övür 4e4cc9d5e3 feat: the session report lists the memory page size and the last process exits (#897) 2026-10-16 09:46:10 -04:00
Resul Övürandovurrsl a353b15364 fix: read Settings.Global phantom process flag on Android 12 (SDK 31) (#955)
On Android 12 (SDK 31), read() only checked the android12-off preference written by in-app Wireless ADB, so disabling phantom process monitoring over USB ADB left the status as UNREADABLE. Also write settings_enable_monitor_phantom_procs in the SDK 31 shell commands and accept a disabled Settings.Global / fflag value in read().

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:37:03 -04:00
Zeyad Saber Refaei Kenawi 86b92c5d0e Add support for android auto (#952)
Add CAR_LAUNCHER intent filter to support android auto
2026-10-16 09:35:39 -04:00
Kurt Himebauch e24e07d0ce Fix Quest CI headers when preloads are cached (#951) 2026-10-16 12:35:55 +00:00
FX1922 71da1c90c5 feat: Change artwork for Steam games; remove “Show more” from the SteamGridDB search. (#939) 2026-10-16 02:08:09 -04:00
kmnkv1 cd4a26b5f1 Sync session logs every 2 s so a hard reset keeps their last minutes (#513) 2026-10-16 01:51:56 -04:00
kmnkv1 66ec776b0b perf: boost the game's busiest threads onto the prime cores on the Odin 3 (#682) 2026-10-16 01:51:00 -04:00
Kurt Himebauch 2847910f22 feat: add immersive Quest SteamVR to the standard app (#910) 2026-10-16 01:48:00 -04:00
Kurt Himebauch d21466be6b update readme (#937)
docs: credit mmap input work
2026-10-15 23:00:15 -04:00
Kurt Himebauch 21a78a1eb2 fix: pace adaptive frame generation on the game's frame rate, not the compositor loop's (#927) 2026-10-15 18:36:40 -04:00
Resul Övür 5aa245cf39 feat: the second-screen Deck controls get the Gamepad tab (#895) 2026-10-15 16:54:43 -04:00
Resul Övür 507ddac39d fix: update zstd-jni so the Linux runtime installs on a 16 KB page-size device (#900) 2026-10-15 16:54:04 -04:00
Kurt Himebauch ef16de6280 feat: storage page to see and free DroidDeck's storage (#913) 2026-10-15 16:45:37 -04:00
410 changed files with 57718 additions and 1846 deletions

No files matched your search

+7
View File
@@ -2,3 +2,10 @@ 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
+81 -15
View File
@@ -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 >/dev/null
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/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,6 +396,30 @@ 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: |
@@ -429,7 +453,7 @@ jobs:
path: |
app/.cxx
app/build/intermediates/cxx
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**', 'tools/vr/src/xr_*.h') }}
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
@@ -482,7 +506,24 @@ jobs:
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
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
- name: Sign with the testkey (v1 + v2 + v3)
run: |
@@ -512,6 +553,15 @@ 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
@@ -520,6 +570,9 @@ 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
@@ -530,8 +583,8 @@ jobs:
echo
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
echo
case "$BUILD_OUTCOME" in
success)
case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
success:success:success)
echo "### Build passed"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
@@ -544,7 +597,28 @@ 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\`."
;;
failure)
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:*)
echo "### ERROR: Build failed"
echo
echo "The compiler's errors ([full log]($RUN_URL)):"
@@ -562,7 +636,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))."
@@ -578,14 +652,6 @@ 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'
+14 -2
View File
@@ -15,7 +15,7 @@ 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, 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.
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.
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.
@@ -29,6 +29,12 @@ 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
@@ -37,4 +43,10 @@ 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. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
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).
+52 -4
View File
@@ -4,6 +4,25 @@ 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 {
@@ -100,6 +119,7 @@ android {
externalNativeBuild {
cmake {
arguments '-DANDROID_STL=c++_shared'
arguments "-DDROIDDECK_VITURE_SDK_DIR=${vitureSdkDir.toString().replace('\\', '/')}"
}
}
}
@@ -128,6 +148,11 @@ 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
@@ -150,6 +175,7 @@ android {
}
buildFeatures {
prefab true
compose true
buildConfig true
}
@@ -157,6 +183,10 @@ 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 {
@@ -168,6 +198,16 @@ 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/**'
@@ -184,12 +224,13 @@ 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 'python3', 'tools/release/runtime_inputs.py', 'verify'
commandLine pythonExecutable, 'tools/release/runtime_inputs.py', 'verify'
}
tasks.configureEach { task ->
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
@@ -197,7 +238,8 @@ tasks.configureEach { task ->
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
def skipRust = (project.findProperty('skipRust')
?: localBuildOptions.getProperty('droiddeck.skipRust', 'false')).toString().toBoolean()
def buildRustStores = tasks.register('buildRustStores', Exec) {
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
onlyIf { !skipRust }
@@ -225,7 +267,8 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@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')
@@ -240,9 +283,14 @@ 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.2-3@jar'
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.7-4@jar'
}
+51 -2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -19,13 +20,19 @@
<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"
@@ -37,6 +44,19 @@
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"
@@ -46,11 +66,13 @@
<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>
@@ -60,6 +82,7 @@
<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>
@@ -106,7 +129,7 @@
android:name=".stores.StoreLoginActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
@@ -128,6 +151,25 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.SecondScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
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"
@@ -167,5 +209,12 @@
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>
+10
View File
@@ -30,6 +30,7 @@ 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)
@@ -37,3 +38,12 @@ 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()
+13 -19
View File
@@ -371,6 +371,17 @@ 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)
@@ -381,15 +392,7 @@ check_ff_event(const struct input_event *ev, uint16_t slot) {
auto it = ff_effects().find(id);
if (it == ff_effects().end())
return;
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);
}
send_ff_effect(it->second, slot);
} else {
send_vibration(0, 0, 0, slot);
}
@@ -3469,16 +3472,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
if (effect->id == -1)
effect->id = next_ff_id++;
ff_effects()[effect->id] = *effect;
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);
}
send_ff_effect(*effect, static_cast<uint16_t>(event_number));
return 0;
} else if (type == 0x45 && number == 0x81) {
int id = (intptr_t)argp;
+13
View File
@@ -0,0 +1,13 @@
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)
+201
View File
@@ -0,0 +1,201 @@
#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;
}
};
+777
View File
@@ -0,0 +1,777 @@
#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);
}
}
+36
View File
@@ -0,0 +1,36 @@
#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.
}
}
}
+308
View File
@@ -0,0 +1,308 @@
#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(); }
}
+27 -10
View File
@@ -34,16 +34,19 @@ 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);
p->fov_deg = clampf(p->fov_deg, 30.0f, 140.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->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->ipd_m * 0.5f;
out[3] = p->mono ? 0.0f : p->ipd_m * 0.5f;
out[7] = p->screen_dist_m;
out[11] = p->screen_width_m * 0.5f;
out[12] = p->k1;
@@ -54,8 +57,9 @@ 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 * 0.5f) / (float)out_h : 1.0f;
out[20] = out[21] = out[22] = out[23] = 0.0f;
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;
}
#ifndef CARDBOARD_HOST_TEST
@@ -81,13 +85,21 @@ 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, 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%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.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.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);
}
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);
@@ -101,6 +113,7 @@ 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);
@@ -119,7 +132,10 @@ 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_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->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);
@@ -401,14 +417,15 @@ 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;
}
if (g_eye_w != out_w / 2 || g_eye_h != out_h) {
snapshot();
int eye_w = g_cur.mono ? out_w : out_w / 2;
if (g_eye_w != eye_w || g_eye_h != out_h) {
pthread_mutex_lock(&g_lock);
g_eye_w = out_w / 2;
g_eye_w = eye_w;
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,9 +40,10 @@ vec3 eyeSample(vec2 tan, float eye) {
}
void main() {
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);
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);
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,11 +15,14 @@ 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);
+271 -253
View File
@@ -1,8 +1,8 @@
#pragma once
const uint32_t cardboard_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b,
0x07230203,0x00010000,0x0008000b,0x00000185,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000f8,0x0000017d,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,255 +11,273 @@ 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,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
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
};
+60 -17
View File
@@ -6,6 +6,7 @@
#include <sys/stat.h>
#include <unistd.h>
#include <stdint.h>
#include <stdatomic.h>
#include <fcntl.h>
#include <time.h>
#include <stdarg.h>
@@ -271,7 +272,9 @@ volatile int g_ubwc = 1;
volatile int g_no_render_node;
volatile int g_output_refresh_mhz;
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_w, g_output_h;
struct pending_release {
@@ -657,6 +660,7 @@ 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);
@@ -740,10 +744,12 @@ 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; droiddeck_on_game_frame(); }
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; g_game_frames++; 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; }
@@ -794,7 +800,7 @@ static void feedback_discard_all(struct wl_list *list) {
}
static uint32_t nominal_refresh_ns(void) {
int mhz = g_output_refresh_mhz;
int mhz = atomic_load(&g_output_refresh_mhz);
if (mhz <= 0) mhz = 60000;
return (uint32_t)(1000000000000LL / (int64_t)mhz);
}
@@ -1507,10 +1513,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) {
static void output_send_mode(struct wl_resource *r, int mhz) {
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,
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
mhz);
}
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
@@ -1520,7 +1526,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);
output_send_mode(r, g_output_announced_refresh_mhz);
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
wl_output_send_name(r, "DroidDeck-1");
wl_output_send_description(r, "DroidDeck display");
@@ -2835,10 +2841,19 @@ static void deliver_key(const struct input_msg *m) {
key_event((uint32_t)m->p1, m->p2);
}
static void scroll_event(int steps) {
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) {
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 || !steps) return;
if (!target || (!vertical120 && !horizontal120)) return;
struct wl_client *client = wl_resource_get_client(target->resource);
struct seat_pointer *sp;
if (!pointer_for(client)) return;
@@ -2847,17 +2862,18 @@ static void scroll_event(int steps) {
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) {
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));
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_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 },
@@ -2913,13 +2929,19 @@ static void key_event(uint32_t evdev, int pressed) {
wl_display_flush_clients(g_display);
}
static void output_resized(void) {
struct surface *s;
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);
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();
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);
@@ -2928,13 +2950,15 @@ 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: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
case 5: vsync = 1; vsync_ns = ((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;
@@ -2942,9 +2966,17 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
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;
}
@@ -2986,6 +3018,16 @@ 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 };
@@ -3223,6 +3265,7 @@ 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,6 +11,7 @@
#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__)
@@ -372,7 +373,11 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
return 0;
}
uint32_t n_gen = fge_plan(g_ring_n, ++g_source_frames, g_presented_rate);
/* 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);
if (n_gen > g_ring_n) n_gen = g_ring_n;
g_planned = n_gen;
@@ -9,6 +9,7 @@
enum {
DROIDDECK_HEADPOSE_LIVE = 1u << 0,
DROIDDECK_HEADPOSE_ENABLED = 1u << 1,
DROIDDECK_HEADPOSE_MONO = 1u << 2,
};
struct droiddeck_headpose {
@@ -22,7 +23,10 @@ struct droiddeck_headpose {
float tan_half_fov;
float eye_aspect;
uint32_t eye_w, eye_h;
uint32_t reserved[12];
float display_hz;
// Uses a formerly reserved word; older publishers leave this zero.
uint32_t render_height;
uint32_t reserved[10];
};
#ifdef __cplusplus
+32 -1
View File
@@ -138,6 +138,9 @@ 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;
@@ -218,26 +221,53 @@ 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) return 0;
if (!w || !detached) { window_request_applied(generation); 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;
}
@@ -248,6 +278,7 @@ 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;
}
@@ -51,6 +51,7 @@ 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);
@@ -27,13 +27,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 +328,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) {
g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0;
droiddeck_wayland_set_output_refresh_rate(hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0);
}
extern void vkp_reset_first_frame(void);
@@ -342,6 +342,7 @@ 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
@@ -350,6 +351,7 @@ 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();
}
@@ -402,10 +404,11 @@ 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) {
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma,
jboolean mono, jfloat hz) {
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};
.chroma = chroma, .mono = mono ? 1 : 0, .display_hz = hz};
vkp_cardboard_set(&p);
}
@@ -592,6 +595,12 @@ 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,11 +16,17 @@ 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,6 +11,7 @@ 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,7 +22,12 @@ 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
@@ -42,7 +48,18 @@ 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 autoCheckedThisProcess = false
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
}
fun state() = GpuDriversState(
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
@@ -52,7 +69,56 @@ 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("")
@@ -90,14 +156,23 @@ 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 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
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
}
fun refreshPairs() {
@@ -121,18 +196,48 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
}.sortedByDescending { it.recommended }
}
fun setMode(auto: Boolean) {
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
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
}
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 (!force && autoCheckedThisProcess) return
autoCheckedThisProcess = true
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(
@@ -148,10 +253,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
releaseChecking = true
autoStatus = activity.getString(R.string.drivers_checking_latest)
Thread({
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
val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity)
ui.post {
releaseChecking = false
refreshReleaseRows()
@@ -165,6 +268,10 @@ 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)
}
}
@@ -174,6 +281,8 @@ 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)
}
@@ -183,8 +292,6 @@ 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
@@ -198,22 +305,27 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
pairBusy = null
pairPercent = -1
result.onSuccess { (displayId, linuxId) ->
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
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
SessionPrefs.setAndroidDriver(activity, displayId)
SessionPrefs.setLinuxDriver(activity, linuxId)
val restart = displayChanged && CompositorHost.isStarted
if (auto) {
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))
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))
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 },
@@ -304,14 +416,15 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
}
setMode(false)
pairModeOpen = false
setMode(auto = false, mix = true)
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(false)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(auto = false, mix = true)
return activity.getString(
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
bundle.label,
@@ -420,6 +533,7 @@ 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,6 +47,7 @@ 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
@@ -108,7 +109,12 @@ 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)
@@ -154,6 +160,9 @@ class MainActivity : ComponentActivity() {
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")
@@ -187,6 +196,10 @@ 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()
@@ -204,6 +217,9 @@ 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) }
}
@@ -248,6 +264,38 @@ 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))
@@ -324,6 +372,11 @@ 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)
@@ -457,24 +510,186 @@ 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
}
if (!SessionService.launchGame(this, game.gameIdString)) {
val steamVr = game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID
if (!steamVr && !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)
.putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
.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)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
@@ -514,6 +729,10 @@ 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)
@@ -570,6 +789,9 @@ class MainActivity : ComponentActivity() {
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherFullscreen = launcherFullscreen,
downloadSource = downloadSource,
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
animationsEnabled = animationsEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
@@ -583,7 +805,13 @@ class MainActivity : ComponentActivity() {
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
onPlay = { playSteam() },
onPlaySteamVr = { playSteamVr() },
onSteamUrl = { url ->
if (!SessionState.running) startSession(
Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true,
)
},
onPlayDesktopUi = {
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
@@ -596,6 +824,8 @@ 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) },
@@ -604,6 +834,7 @@ 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 ->
@@ -743,6 +974,18 @@ class MainActivity : ComponentActivity() {
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)
@@ -816,6 +1059,7 @@ 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 },
@@ -843,6 +1087,15 @@ 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),
@@ -850,6 +1103,20 @@ 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 },
@@ -867,6 +1134,10 @@ 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) {
@@ -902,8 +1173,21 @@ class MainActivity : ComponentActivity() {
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
private fun startSteamSession() {
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
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)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
@@ -965,7 +1249,7 @@ class MainActivity : ComponentActivity() {
refreshPhantomStatus()
syncLossless()
updates.onResume()
drivers.ensureAuto(force = false)
drivers.checkOnOpen()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1000,6 +1284,19 @@ 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()
@@ -1027,7 +1324,9 @@ class MainActivity : ComponentActivity() {
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
secondScreenDisplays = SecondScreenDisplays.available(displayManager)
// 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)
}
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
@@ -1161,6 +1460,8 @@ 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,
@@ -1268,6 +1569,11 @@ 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,
@@ -1348,6 +1654,12 @@ 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 },
@@ -1418,6 +1730,20 @@ 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()
@@ -1465,6 +1791,7 @@ 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)
@@ -1537,6 +1864,7 @@ 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)
@@ -1556,6 +1884,7 @@ 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 }
@@ -1584,7 +1913,11 @@ class MainActivity : ComponentActivity() {
}
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
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
}
shortcutLibraryScanning = false
handleGameLink(all)
}
@@ -1613,6 +1946,10 @@ 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
@@ -1726,6 +2063,12 @@ 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")
}
@@ -0,0 +1,118 @@
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,11 +92,75 @@ 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()
@@ -109,6 +173,7 @@ 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,8 +136,12 @@ object AgentGuest {
return null
}
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
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)
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,6 +97,7 @@ 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,10 +2,15 @@ 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
@@ -32,6 +37,57 @@ 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) {
@@ -68,6 +124,20 @@ 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)
@@ -82,6 +152,19 @@ 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))
@@ -99,6 +182,13 @@ 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)
@@ -0,0 +1,207 @@
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,69 +6,201 @@ 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 TIMEOUT_MS = 30_000;
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 Downloader() {}
public static String downloadString(String url) {
HttpURLConnection connection = null;
try {
connection = open(url);
if (connection.getResponseCode() / 100 != 2) {
Log.w(TAG, url + " -> HTTP " + connection.getResponseCode());
return null;
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;
}
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();
if (fetch.fatal && sources.get(i).equals(url)) return null;
}
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 {
connection = open(url);
if (have > 0) connection.setRequestProperty("Range", "bytes=" + have + "-");
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);
}
int code = connection.getResponseCode();
boolean appending = code == HttpURLConnection.HTTP_PARTIAL;
if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) {
// A 206 that does not begin where the kept bytes end would corrupt the file if appended.
if (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) {
connection.disconnect();
destination.delete();
return downloadFile(url, destination, false, progress);
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
}
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;
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 (!appending) have = 0;
long length = connection.getContentLengthLong();
long total = length < 0 ? -1 : length + have;
long total = appending ? rangeTotal(connection.getHeaderField("Content-Range")) : -1;
if (total < 0) total = length < 0 ? -1 : length + have;
File parent = destination.getParentFile();
if (parent != null && !parent.isDirectory())
parent.mkdirs();
if (parent != null && !parent.isDirectory()) parent.mkdirs();
identity.source = url;
identity.etag = strongETag(etag) ? etag : null;
saveIdentity(destination, identity, expectedSha256);
long written = have;
try (InputStream in = new BufferedInputStream(connection.getInputStream(), 1 << 16);
OutputStream out = new FileOutputStream(destination, appending)) {
@@ -83,11 +215,11 @@ public final class Downloader {
}
}
}
if (progress != null) progress.accept(1f);
return total <= 0 || written >= total;
if (total > 0 && written < total) return Outcome.RETRY;
return Outcome.DONE;
} catch (Exception e) {
Log.w(TAG, "download " + url, e);
return false;
return Outcome.RETRY;
} finally {
if (connection != null) connection.disconnect();
}
@@ -100,10 +232,49 @@ public final class Downloader {
return m.matches() && Long.parseLong(m.group(1)) == start;
}
private static HttpURLConnection open(String url) throws java.io.IOException {
/** 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 {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(readTimeoutMs);
connection.setInstanceFollowRedirects(true);
connection.setRequestProperty("User-Agent", "DroidDeck-Android");
return connection;
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.session.SessionPrefs
object GameEnvironment {
data class Config(
val shared: Map<String, String?> = emptyMap(),
@@ -83,4 +85,34 @@ 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,10 +26,12 @@ 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"}")
}
@@ -57,7 +59,12 @@ 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)
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
val global = try {
Settings.Global.getString(context.contentResolver, SETTING)
} catch (_: Exception) {
null
}
return android12Status(off, global, systemProperty(OVERRIDE_PROPERTY))
}
val global = try {
Settings.Global.getString(context.contentResolver, SETTING)
@@ -67,6 +74,10 @@ 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,6 +4,7 @@ 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"
@@ -11,6 +12,7 @@ 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) {
@@ -37,6 +39,7 @@ 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)
@@ -49,8 +52,19 @@ 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), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
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),
}
const val FOLDER = "folder"
@@ -40,10 +40,10 @@ object AddedGameArt {
class Found<T>(val items: List<T>, val status: SteamGridDb.Status)
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
private val CHOSEN = Regex("""chosen-(p|header|hero|logo|icon)(?:-\d+)?\.(\w+)""")
private fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
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 val IMAGE = listOf("png", "jpg", "jpeg")
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
@@ -79,7 +79,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 = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
header = chosen(cache, Slot.WIDE) ?: 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,6 +96,7 @@ 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 }
@@ -117,36 +118,37 @@ object AddedGameArt {
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()
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))
}
var status = SteamGridDb.Status.OK
val key = SteamGridDb.key(context)
if (key.isEmpty()) {
status = SteamGridDb.Status.NO_KEY
} else {
cache.mkdirs()
try {
val gridId = remembered(File(cache, "sgdb-id"), SteamGridDb.keyChangedAt(context), retryMiss = true) {
SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name)?.toString()
}?.toIntOrNull()
if (gridId == null) status = SteamGridDb.Status.NO_GAME
else SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5)
.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
} catch (e: Exception) {
Log.w(TAG, "${game.name}: SteamGridDB failed (${e.javaClass.simpleName})")
status = (e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER
}
}
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)
}
}
fun searchGames(context: Context, term: String): Found<SteamGridDb.Game> = sgdb(context) { SteamGridDb.search(it, term) }
fun gameImages(context: Context, id: Int, slot: Slot, page: Int): Found<Option> = sgdb(context) { key ->
SteamGridDb.page(key, id, slot, page).map { (thumb, full) -> Option(SGDB, thumb, full) }
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> {
@@ -161,19 +163,38 @@ object AddedGameArt {
}
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
bytes ?: return false
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
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)
if (!tmp.renameTo(dst)) return false
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
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 tmp = File(cache, dst.name + ".tmp")
tmp.writeBytes(bytes)
return if (tmp.renameTo(dst)) dst else null
}
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
val value = SessionPrefs.addedGameArtSource(context, game.folder.path, slot.key)
return value.substringBefore('|') to value.substringAfter('|', "")
@@ -36,19 +36,34 @@ object AddedGameEdits {
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
val game = game(context, folder) ?: return false
if (!AddedGameArt.choose(context, game, slot, option)) return false
applyArt(context, game, slot)
return true
return synchronized(SteamGrid.lock) {
if (!AddedGameArt.choose(context, game, slot, option)) return@synchronized false
applyArt(context, game, slot)
true
}
}
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
val game = game(context, folder) ?: return
AddedGameArt.reset(context, game, slot)
applyArt(context, game, slot)
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
}
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,6 +6,7 @@ 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
@@ -296,13 +297,16 @@ 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 launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
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 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
@@ -330,7 +334,7 @@ object AddedGames {
}
}
private fun accountConfig(root: File): File? {
internal 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]) }
@@ -343,6 +347,9 @@ 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()
@@ -382,7 +389,8 @@ object AddedGames {
}
}
fun writeListing(context: Context, games: List<Game>): File {
fun writeListing(context: Context, games: List<Game>,
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
val json = StringBuilder("[")
games.forEachIndexed { i, g ->
@@ -399,6 +407,10 @@ 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)
}
private fun constrained(context: Context): Boolean {
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,6 +3,7 @@ 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
@@ -199,24 +200,62 @@ object CoverArt {
setRequestProperty("User-Agent", "DroidDeck")
}
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 <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 get(url: String): String? = try {
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 {
val c = open(url)
if (c.responseCode != 200) { c.disconnect(); null } else c.inputStream.bufferedReader().use { it.readText() }
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 }
}
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
private fun download(url: String, target: File): Boolean = try {
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 {
val c = open(url)
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 }
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 }
}
} catch (e: Exception) { Log.w(TAG, "$url: $e"); false }
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
}
@@ -17,10 +17,15 @@ 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)
@@ -69,7 +74,7 @@ object Library {
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private fun steamLibraries(context: Context): List<Pair<File, String>> {
internal fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return listOfNotNull(
root to "internal",
@@ -116,13 +121,13 @@ 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] = SteamGame(
out[appId] = SteamGameArt.withOverrides(context, 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()
@@ -138,7 +143,11 @@ object Library {
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
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 }
}
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
@@ -152,7 +161,7 @@ object Library {
)
}
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
internal 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 }
@@ -185,7 +194,8 @@ 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 specs.firstOrNull { it.program == program }?.name
} else com.droiddeck.launcher.session.NoSteamLaunch.nameFor(program, com.droiddeck.launcher.session.SessionState.programArgs)
?: specs.firstOrNull { it.program == program }?.name
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
@@ -0,0 +1,48 @@
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
}
}
@@ -0,0 +1,99 @@
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
}
}
@@ -0,0 +1,205 @@
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
}
}
@@ -118,23 +118,42 @@ object SteamGridDb {
.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, page: Int): String {
val base = when (slot) {
internal fun path(id: Int, slot: AddedGameArt.Slot): String =
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"
}
return if (page > 0) "$base&page=$page" else base
}
fun page(key: String, id: Int, slot: AddedGameArt.Slot, page: Int): List<Pair<String, String>> {
val items = json(key, "$API/${path(id, slot, page)}")?.optJSONArray("data") ?: return emptyList()
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)
}
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, 0).take(limit)
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) }
@@ -0,0 +1,862 @@
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()
}
}
@@ -0,0 +1,25 @@
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()
}
@@ -0,0 +1,84 @@
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
}
}
@@ -0,0 +1,67 @@
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,
)
}
}
@@ -0,0 +1,51 @@
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 }
}
}
}
@@ -0,0 +1,105 @@
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
}
}
@@ -0,0 +1,202 @@
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()
}
}
}
}
@@ -0,0 +1,42 @@
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
}
}
@@ -0,0 +1,190 @@
// 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)
}
}
@@ -0,0 +1,270 @@
// 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
}
}
@@ -0,0 +1,134 @@
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 }
}
}
@@ -0,0 +1,186 @@
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(),
)
}
}
@@ -0,0 +1,52 @@
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()
}
}
@@ -0,0 +1,132 @@
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
}
}
}
}
@@ -0,0 +1,88 @@
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()
}
}
@@ -0,0 +1,201 @@
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) }))
}
}
@@ -0,0 +1,27 @@
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
}
@@ -0,0 +1,46 @@
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))
}
}
@@ -0,0 +1,78 @@
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
}
@@ -0,0 +1,101 @@
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,13 +12,15 @@ 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) {
fun push(panelWidthMm: Float, panelHeightMm: Float, fovOverrideDeg: Float = 0f, displayHz: Float = 0f) {
val l = lens
WaylandCompositor.nativeSetCardboard(enabled, source, ipdMm / 1000f, screenDistanceDm / 10f,
screenWidthDm / 10f, l.k1, l.k2, fovDeg(l, panelHeightMm), lensInset(panelWidthMm, l.lensSeparationMm), l.chroma)
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)
}
fun encode(): JSONObject = JSONObject()
@@ -29,7 +31,8 @@ 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);
NONE("none", 0f, 0f, 0f, 0f, 0f, 60f),
VITURE("viture", 0f, 0f, 0f, 0f, 0f, 23.3f);
companion object {
fun byId(id: String): Viewer = entries.firstOrNull { it.id == id } ?: CARDBOARD_V2
@@ -123,6 +126,10 @@ 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(
@@ -10,19 +10,42 @@ import android.os.HandlerThread
import android.util.Log
import com.droiddeck.launcher.wayland.WaylandCompositor
class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEventListener {
class HeadTracker(context: Context, private val glasses: Boolean = false, 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)
@@ -37,6 +60,15 @@ class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEve
}
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()
@@ -46,7 +78,8 @@ class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEve
fun recenter() {
recenterPending = true
latest?.let { apply(it) }
if (glasses) synchronized(glassesLock) { if (started) latest?.let { apply(it) } }
else latest?.let { apply(it) }
}
override fun onSensorChanged(event: SensorEvent) {
@@ -5,6 +5,8 @@ import com.droiddeck.launcher.agent.AgentGuest
import org.json.JSONObject
object SteamVr {
const val APP_ID = "250820"
enum class Status {
UNAVAILABLE,
NOT_INSTALLED,
@@ -13,6 +15,23 @@ 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)
@@ -0,0 +1,86 @@
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
}
}
@@ -0,0 +1,83 @@
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"
}
}
@@ -0,0 +1,27 @@
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
}
}
@@ -0,0 +1,72 @@
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
})
}
}
}
}
@@ -0,0 +1,36 @@
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}") }
}
}
@@ -1,18 +1,21 @@
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"
@@ -104,7 +107,24 @@ object TurnipReleases {
}
private fun get(context: Context, url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
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
c.connectTimeout = 15_000
c.readTimeout = 20_000
c.setRequestProperty("Accept", "application/vnd.github+json")
@@ -160,39 +180,13 @@ 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 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) {
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)) {
FileUtils.delete(target)
throw e
} finally {
c.disconnect()
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
}
return target
}
private fun downloads(context: Context): JSONObject =
@@ -0,0 +1,255 @@
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
}
@@ -0,0 +1,323 @@
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
}
}
@@ -0,0 +1,203 @@
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()
}
}
}
@@ -0,0 +1,103 @@
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()
}
}
@@ -0,0 +1,172 @@
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))
}
}
}
@@ -0,0 +1,110 @@
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
}
}
@@ -0,0 +1,136 @@
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,6 +47,7 @@ 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>,
@@ -67,6 +68,7 @@ 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) },
@@ -127,6 +129,12 @@ 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()
}
@@ -119,6 +119,10 @@ 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);
@@ -0,0 +1,143 @@
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
}
}
@@ -0,0 +1,213 @@
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."
}
@@ -0,0 +1,113 @@
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
}
}
@@ -0,0 +1,116 @@
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()
}
}
@@ -0,0 +1,363 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.BatteryState
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.CombinedVibration
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.os.Process
import android.os.VibrationEffect
import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.annotation.RequiresApi
import java.io.File
import kotlin.math.min
import kotlin.math.sqrt
@RequiresApi(31)
class JoyConVr(
private val context: Context,
private val path: File,
private val onDevicesChanged: (taken: List<Int>, released: List<Int>) -> Unit,
) {
private val thread = HandlerThread("joycon-vr", Process.THREAD_PRIORITY_DISPLAY).apply { start() }
private val worker = Handler(thread.looper)
private val main = Handler(Looper.getMainLooper())
@Volatile private var owned = IntArray(0)
private var wanted = false
private var closed = false
private var retries = 0
private var shm: JoyConShm? = null
private val hands = arrayOfNulls<Hand>(2)
private val retry = Runnable { apply() }
fun setActive(on: Boolean) {
worker.post {
if (closed || wanted == on) return@post
wanted = on
retries = 0
apply()
}
}
fun refresh() {
worker.post {
if (closed) return@post
retries = 0
apply()
}
}
fun owns(deviceId: Int): Boolean = owned.contains(deviceId)
val holding: Boolean get() = owned.isNotEmpty()
fun onKeyEvent(event: KeyEvent): Boolean {
if (!owns(event.deviceId)) return false
if (event.repeatCount > 0) return true
val down = when (event.action) {
KeyEvent.ACTION_DOWN -> true
KeyEvent.ACTION_UP -> false
else -> return true
}
val id = event.deviceId
val scanCode = event.scanCode
worker.post { hand(id)?.key(scanCode, down) }
return true
}
fun onMotionEvent(event: MotionEvent): Boolean {
if (!owns(event.deviceId)) return false
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK) || event.actionMasked != MotionEvent.ACTION_MOVE) return true
val device = event.device ?: return true
val side = JoyCon.side(device)
if (side < 0) return true
val axes = JoyCon.stickAxes(side, device, event.source)
val x = event.getAxisValue(axes[0])
val y = event.getAxisValue(axes[1])
val id = event.deviceId
worker.post { hand(id)?.stick(x, -y) }
return true
}
fun releaseButtons() {
worker.post { hands.forEach { it?.release() } }
}
fun close() {
worker.post {
closed = true
wanted = false
apply()
shm?.close()
shm = null
}
thread.quitSafely()
}
private fun hand(id: Int): Hand? = hands.firstOrNull { it?.device?.id == id }
private fun apply() {
worker.removeCallbacks(retry)
val found = arrayOfNulls<InputDevice>(2)
var waiting = false
if (wanted && !closed) {
for (id in InputDevice.getDeviceIds()) {
val device = InputDevice.getDevice(id) ?: continue
val side = JoyCon.side(device)
if (side < 0 || found[side] != null) continue
if (hasMotion(device)) found[side] = device else waiting = true
}
}
val before = owned
for (side in 0..1) {
val current = hands[side]
val device = found[side]
if (current != null && current.device.id == device?.id) continue
current?.stop()
hands[side] = null
if (device != null) {
hands[side] = start(side, device)
if (hands[side] == null) waiting = true
}
}
val now = hands.mapNotNull { it?.device?.id }.toIntArray()
owned = now
if (closed) JoyCon.clearVrDevices(this)
else JoyCon.setVrDevices(this, IntArray(2) { hands[it]?.device?.id ?: PadBridge.NO_CONTROLLER })
if (!closed) {
val taken = now.filter { it !in before }
val released = before.filter { it !in now }
if (taken.isNotEmpty() || released.isNotEmpty()) main.post { onDevicesChanged(taken, released) }
}
if (wanted && waiting && retries < MAX_RETRIES) {
retries++
worker.postDelayed(retry, RETRY_MS)
}
}
private fun hasMotion(device: InputDevice): Boolean =
runCatching {
val sensors = device.sensorManager
sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null && sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
}.getOrDefault(false)
private fun start(side: Int, device: InputDevice): Hand? {
val out = shm ?: JoyConShm.open(path)?.also { shm = it } ?: return null
val sensors = runCatching { device.sensorManager }.getOrNull() ?: return null
val gyro = runCatching { sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) }.getOrNull() ?: return null
val accel = runCatching { sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) }.getOrNull() ?: return null
val hand = Hand(side, device, sensors, out)
val listening = runCatching {
sensors.registerListener(hand, accel, SensorManager.SENSOR_DELAY_FASTEST, worker) &&
sensors.registerListener(hand, gyro, SensorManager.SENSOR_DELAY_FASTEST, worker)
}.onFailure { Log.w(TAG, "Joy-Con motion unavailable for \"${device.name}\"", it) }.getOrDefault(false)
if (!listening) {
runCatching { sensors.unregisterListener(hand) }
return null
}
Log.i(TAG, "${SIDES[side]} Joy-Con \"${device.name}\" (id ${device.id}) is a VR controller")
return hand
}
private inner class Hand(
val side: Int,
val device: InputDevice,
private val sensors: SensorManager,
private val out: JoyConShm,
) : SensorEventListener {
private val filter = JoyConOrientation()
private val motion = JoyConMotion()
private val calibration = JoyConAccelCalibration().apply { restore(JoyConCalibrations.load(context, device.descriptor)) }
private val reports = JoyConReports()
private val accel = FloatArray(3)
private var accelStamp = 0L
private var haveAccel = false
private val pressed = HashSet<Int>()
private var stickX = 0f
private var stickY = 0f
private var systemDown = false
private var systemTap = false
private var held = false
private var recenter = 0
private var battery = -1f
private var charging = false
private var batteryAt = 0L
private var sampleAt = 0L
private var live = true
private val hold = Runnable {
if (!systemDown || held) return@Runnable
held = true
recenter++
confirm()
publish()
}
private val tapEnd = Runnable {
systemTap = false
publish()
}
private val flush = Runnable { flushReport() }
override fun onSensorChanged(event: SensorEvent) {
if (!live) return
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
if (event.timestamp == accelStamp) return
accelStamp = event.timestamp
event.values.copyInto(accel, 0, 0, 3)
haveAccel = true
}
Sensor.TYPE_GYROSCOPE -> {
if (!haveAccel) return
reports.add(accel, event.values, System.nanoTime())
worker.removeCallbacks(flush)
if (reports.size >= JoyConReports.SAMPLES_PER_REPORT) flushReport() else worker.postDelayed(flush, FLUSH_MS)
}
}
}
private fun flushReport() {
val samples = reports.flush()
if (samples.isEmpty() || !live) return
for (sample in samples) {
val raw = JoyConOrientation.axes(sample.accel)
val corrected = calibration.correct(raw)
filter.update(corrected, JoyConOrientation.axes(sample.gyro), sample.dt)
if (!filter.ready) continue
motion.update(filter.rotation, corrected, filter.rate, calibration.ready, sample.dt)
if (motion.resting && calibration.addResting(raw)) JoyConCalibrations.save(context, device.descriptor, calibration.saved())
}
val now = System.nanoTime()
sampleAt = now
if (now - batteryAt > BATTERY_NS) refreshBattery(now)
publish()
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
fun key(scanCode: Int, down: Boolean) {
if (!live) return
val bit = JoyCon.vrButton(side, scanCode)
if (bit == 0) return
if (bit == JoyCon.VR_SYSTEM) {
system(down)
return
}
if (down) pressed += scanCode else pressed -= scanCode
publish()
}
fun stick(x: Float, y: Float) {
if (!live) return
val length = sqrt(x * x + y * y)
if (length < STICK_DEADZONE) {
stickX = 0f
stickY = 0f
} else {
val scale = min((length - STICK_DEADZONE) / (1f - STICK_DEADZONE), 1f) / length
stickX = x * scale
stickY = y * scale
}
publish()
}
fun release() {
if (!live) return
pressed.clear()
stickX = 0f
stickY = 0f
systemDown = false
systemTap = false
held = false
worker.removeCallbacks(hold)
worker.removeCallbacks(tapEnd)
publish()
}
fun stop() {
live = false
runCatching { sensors.unregisterListener(this) }
worker.removeCallbacks(hold)
worker.removeCallbacks(tapEnd)
worker.removeCallbacks(flush)
out.write(side, JoyConShm.Hand(connected = false))
Log.i(TAG, "${SIDES[side]} Joy-Con \"${device.name}\" (id ${device.id}) is a game pad again")
}
private fun system(down: Boolean) {
if (down == systemDown) return
systemDown = down
if (down) {
held = false
worker.postDelayed(hold, HOLD_MS)
return
}
worker.removeCallbacks(hold)
if (held) return
systemTap = true
publish()
worker.removeCallbacks(tapEnd)
worker.postDelayed(tapEnd, TAP_MS)
}
private fun buttons(): Int {
var bits = if (systemTap) JoyCon.VR_SYSTEM else 0
for (scanCode in pressed) bits = bits or JoyCon.vrButton(side, scanCode)
return bits
}
private fun publish() {
out.write(side, JoyConShm.Hand(
connected = filter.ready,
charging = charging,
buttons = buttons(),
rotation = filter.rotation,
rate = filter.rate,
displacement = motion.displacement,
velocity = motion.velocity,
inertial = motion.inertial,
stickX = stickX,
stickY = stickY,
battery = battery,
recenter = recenter,
timeNs = sampleAt,
))
}
private fun refreshBattery(now: Long) {
batteryAt = now
val state = runCatching { device.batteryState }.getOrNull() ?: return
battery = if (state.isPresent && !state.capacity.isNaN()) state.capacity else -1f
charging = state.isPresent && state.status == BatteryState.STATUS_CHARGING
}
private fun confirm() {
if (!ControllerPrefs.rumbleEnabled(context)) return
runCatching {
device.vibratorManager.vibrate(CombinedVibration.createParallel(
VibrationEffect.createOneShot(CONFIRM_MS, VibrationEffect.DEFAULT_AMPLITUDE)))
}
}
}
companion object {
const val FILE = JoyConShm.FILE
private const val TAG = "JoyConVr"
private val SIDES = arrayOf("left", "right")
private const val MAX_RETRIES = 6
private const val RETRY_MS = 500L
private const val HOLD_MS = 800L
private const val TAP_MS = 120L
private const val CONFIRM_MS = 80L
private const val BATTERY_NS = 30_000_000_000L
private const val STICK_DEADZONE = 0.12f
private const val FLUSH_MS = 3L
}
}
@@ -92,6 +92,7 @@ class OnScreenControls(
private val groups = controls.map { it.group }.distinct()
private var buttonsOnly = false
val isButtonsOnly: Boolean get() = buttonsOnly
private var quickHidden = false
private var quickPressedBy = -1
private var keyboardPressedBy = -1
@@ -30,6 +30,7 @@ public final class PadBridge {
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
};
private static final int[] ROUTED_PASS_THROUGH = {PadState.GUIDE, PadState.QAM, PadState.SELECT};
private static final long QAM_GUIDE_LEAD_MIN_MS = 80;
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
private static final int QAM_GUIDE_LEAD_FRAMES = 8;
@@ -46,12 +47,16 @@ public final class PadBridge {
private final FakeInputWriter writer;
private final PadState state = new PadState();
private final int[] owners = new int[SLOTS];
private final int[] partners = new int[SLOTS];
private volatile java.util.function.IntPredicate excluded = id -> false;
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
private final PadState[] playerStates = new PadState[SLOTS];
private final PadState effectiveState = new PadState();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private TouchRoute touchRoute;
private final PadState routedTouch = new PadState();
private boolean systemGuidePressed;
private boolean systemQamPressed;
private boolean qamChordActive;
@@ -72,8 +77,10 @@ public final class PadBridge {
public static final int NO_CONTROLLER = -1;
private static volatile int activeControllerId = NO_CONTROLLER;
private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS);
private static final AtomicIntegerArray slotJoyCons = new AtomicIntegerArray(SLOTS * 2);
static {
for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER);
for (int i = 0; i < SLOTS * 2; i++) slotJoyCons.set(i, NO_CONTROLLER);
}
public static int activeControllerId() { return activeControllerId; }
@@ -82,10 +89,16 @@ public final class PadBridge {
return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId;
}
public static int joyConForSlot(int slot, int side) {
return slot >= 0 && slot < SLOTS && (side == JoyCon.LEFT || side == JoyCon.RIGHT)
? slotJoyCons.get(slot * 2 + side) : NO_CONTROLLER;
}
public PadBridge(File fakeInputDir) {
this.fakeInputDir = fakeInputDir;
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0);
Arrays.fill(owners, NO_CONTROLLER);
Arrays.fill(partners, NO_CONTROLLER);
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
}
@@ -93,6 +106,10 @@ public final class PadBridge {
selectSteam = on;
}
public void setExcluded(java.util.function.IntPredicate devices) {
excluded = devices;
}
public synchronized boolean start() {
if (!open) {
open = writer.open();
@@ -106,7 +123,9 @@ public final class PadBridge {
activeControllerId = NO_CONTROLLER;
for (int slot = 0; slot < SLOTS; slot++) {
owners[slot] = NO_CONTROLLER;
partners[slot] = NO_CONTROLLER;
slotControllers.set(slot, NO_CONTROLLER);
clearJoyCons(slot);
if (playerWriters[slot] != null) {
playerWriters[slot].destroy();
playerWriters[slot] = null;
@@ -206,7 +225,7 @@ public final class PadBridge {
public synchronized void syncConnectedControllers() {
for (int deviceId : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(deviceId);
if (isFromController(device)) publish(slotFor(device));
if (isFromController(device) && !excluded.test(deviceId)) publish(slotFor(device));
}
}
@@ -224,8 +243,10 @@ public final class PadBridge {
}
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isControllerKeyEvent(event)) return false;
if (!isControllerKeyEvent(event) || excluded.test(event.getDeviceId())) return false;
int slot = slotFor(event.getDevice());
int side = JoyCon.side(event.getDevice());
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
PadState pad = stateFor(slot);
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
@@ -269,36 +290,37 @@ public final class PadBridge {
}
public synchronized boolean onMotionEvent(MotionEvent event) {
if (!isControllerMotionEvent(event)) return false;
if (!isControllerMotionEvent(event) || excluded.test(event.getDeviceId())) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
int slot = slotFor(event.getDevice());
PadState pad = stateFor(slot);
noteDevice(event.getDevice(), slot);
statAxes++;
scheduleStats();
InputDevice device = event.getDevice();
int side = JoyCon.side(device);
if (side >= 0) {
int[] stick = JoyCon.stickAxes(side, device, event.getSource());
float x = axis(event, stick[0]);
float y = axis(event, stick[1]);
if (side == JoyCon.LEFT) {
pad.leftX = x;
pad.leftY = y;
} else {
pad.rightX = x;
pad.rightY = y;
}
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
publish(slot);
return true;
}
pad.leftX = axis(event, MotionEvent.AXIS_X);
pad.leftY = axis(event, MotionEvent.AXIS_Y);
InputDevice device = event.getDevice();
boolean wiredXboxLayout = device != null
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
pad.rightX = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z);
pad.rightY = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ);
float lt;
float rt;
if (wiredXboxLayout) {
lt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f));
rt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f));
} else {
lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
}
boolean wired = wiredXboxLayout(event);
pad.rightX = rightX(event, wired);
pad.rightY = rightY(event, wired);
float lt = leftTrigger(event, wired);
float rt = rightTrigger(event, wired);
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
@@ -315,7 +337,36 @@ public final class PadBridge {
return true;
}
public interface TouchRoute {
boolean active();
void accept(PadState state);
}
public synchronized void setTouchRoute(TouchRoute route) {
touchRoute = route;
routedTouch.clear();
}
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
if (touchRoute != null && touchRoute.active()) {
mutation.accept(routedTouch);
touchRoute.accept(routedTouch);
boolean changed = false;
for (int button : ROUTED_PASS_THROUGH) {
boolean down = routedTouch.isDown(button);
if (state.isDown(button) == down) continue;
state.press(button, down);
changed = true;
}
if (changed) {
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
publish();
}
return;
}
routedTouch.clear();
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
@@ -346,6 +397,7 @@ public final class PadBridge {
}
public synchronized void releaseAll() {
routedTouch.clear();
releaseBackPaddles();
state.clear();
publish();
@@ -358,7 +410,7 @@ public final class PadBridge {
public synchronized void onDeviceRemoved(int deviceId) {
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] == deviceId) free(slot);
if (owners[slot] == deviceId || partners[slot] == deviceId) leave(slot, deviceId);
}
}
@@ -425,12 +477,26 @@ public final class PadBridge {
private int slotFor(InputDevice device) {
int id = device.getId();
if (owners[0] == id) return 0;
if (owners[0] == id || partners[0] == id) return 0;
for (int slot = 1; slot < SLOTS; slot++) {
if (owners[slot] == id) return slot;
if (owners[slot] == id || partners[slot] == id) return slot;
}
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
if (partners[slot] != NO_CONTROLLER && InputDevice.getDevice(partners[slot]) == null) leave(slot, partners[slot]);
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) leave(slot, owners[slot]);
}
int side = JoyCon.side(device);
for (int slot = 0; side >= 0 && slot < SLOTS; slot++) {
if (owners[slot] == NO_CONTROLLER || partners[slot] != NO_CONTROLLER) continue;
InputDevice owner = InputDevice.getDevice(owners[slot]);
if (JoyCon.side(owner) != 1 - side) continue;
partners[slot] = id;
slotJoyCons.set(slot * 2 + side, id);
slotJoyCons.set(slot * 2 + 1 - side, owner.getId());
if (slot == 0) releaseBackPaddles();
stateFor(slot).clear();
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") joins \"" + owner.getName() + "\" as player " + (slot + 1));
return slot;
}
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER) continue;
@@ -446,7 +512,8 @@ public final class PadBridge {
}
private boolean isPrimary(KeyEvent event) {
return owners[0] == event.getDevice().getId();
int id = event.getDevice().getId();
return owners[0] == id || partners[0] == id;
}
private void releaseBackPaddles() {
@@ -454,9 +521,32 @@ public final class PadBridge {
DeckControls.INSTANCE.setGrip(DeckControls.R4, false);
}
private void leave(int slot, int id) {
if (partners[slot] == NO_CONTROLLER) {
free(slot);
return;
}
Log.i(TAG, "player " + (slot + 1) + " Joy-Con (id " + id + ") left");
if (owners[slot] == id) owners[slot] = partners[slot];
partners[slot] = NO_CONTROLLER;
clearJoyCons(slot);
if (slotControllers.get(slot) == id) slotControllers.set(slot, owners[slot]);
if (activeControllerId == id) activeControllerId = owners[slot];
if (slot == 0) releaseBackPaddles();
stateFor(slot).clear();
publish(slot);
}
private static void clearJoyCons(int slot) {
slotJoyCons.set(slot * 2, NO_CONTROLLER);
slotJoyCons.set(slot * 2 + 1, NO_CONTROLLER);
}
private void free(int slot) {
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
owners[slot] = NO_CONTROLLER;
partners[slot] = NO_CONTROLLER;
clearJoyCons(slot);
slotControllers.set(slot, NO_CONTROLLER);
if (slot == 0) {
releaseBackPaddles();
@@ -535,6 +625,37 @@ public final class PadBridge {
writer.writePad(effectiveState);
}
public static boolean wiredXboxLayout(MotionEvent event) {
InputDevice device = event.getDevice();
return device != null
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
}
public static float rightX(MotionEvent event, boolean wired) {
return axis(event, wired ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z);
}
public static float rightY(MotionEvent event, boolean wired) {
return axis(event, wired ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ);
}
public static float leftTrigger(MotionEvent event, boolean wired) {
if (wired) return Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f));
float value = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
return value != 0f ? value : event.getAxisValue(MotionEvent.AXIS_BRAKE);
}
public static float rightTrigger(MotionEvent event, boolean wired) {
if (wired) return Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f));
float value = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
return value != 0f ? value : event.getAxisValue(MotionEvent.AXIS_GAS);
}
private static float axis(MotionEvent event, int axis) {
float value = event.getAxisValue(axis);
return Math.abs(value) < DEAD_ZONE ? 0f : value;
@@ -0,0 +1,358 @@
package com.droiddeck.launcher.input
import android.os.SystemClock
import android.view.Choreographer
import android.view.KeyEvent
import android.view.MotionEvent
import kotlin.math.abs
import kotlin.math.hypot
class PadMouse(private val listener: Listener, private val clock: () -> Long = SystemClock::uptimeMillis) {
interface Listener {
fun active(): Boolean
fun onScrollStart()
fun onScroll(dx: Float, dy: Float)
fun onKey(evdev: Int, pressed: Boolean)
fun onKeyboard()
fun onNavigate()
fun scrollSpeed(): Float
}
private class Sticks {
var leftX = 0f
var leftY = 0f
var rightX = 0f
var rightY = 0f
var leftTrigger = 0f
var rightTrigger = 0f
fun clear() {
leftX = 0f
leftY = 0f
rightX = 0f
rightY = 0f
leftTrigger = 0f
rightTrigger = 0f
}
}
private val choreographer = Choreographer.getInstance()
private val physical = HashMap<Int, Sticks>()
private val touch = Sticks()
private val total = Sticks()
private val triggerAxes = HashMap<Int, Boolean>()
private val wiredLayout = HashMap<Int, Boolean>()
private val down = HashMap<Int, MutableSet<Int>>()
private val sent = HashMap<Int, IntArray>()
private val held = LinkedHashMap<Int, Int>()
private var scrolling = false
private var stickDirection = NONE
private var stickRepeatAt = 0L
private var frameAt = 0L
private var running = false
private val frame = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!running) return
if (!listener.active()) {
reset()
return
}
val dt = if (frameAt == 0L) 1f / 60f else ((frameTimeNanos - frameAt) / 1e9f).coerceIn(0f, 0.05f)
frameAt = frameTimeNanos
step(dt)
if (moving()) choreographer.postFrameCallback(this) else stop()
}
}
fun onKey(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_L2 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R2) {
if (event.repeatCount == 0 && !hasTriggerAxes(event)) {
val sticks = physical.getOrPut(event.deviceId) { Sticks() }
val value = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_L2) sticks.leftTrigger = value else sticks.rightTrigger = value
wake()
}
return true
}
val button = BUTTONS[event.keyCode] ?: return false
if (event.repeatCount > 0) return true
when (event.action) {
KeyEvent.ACTION_DOWN -> set(source(event.deviceId, SOURCE_KEYS), button, true)
KeyEvent.ACTION_UP -> set(source(event.deviceId, SOURCE_KEYS), button, false)
}
return true
}
fun onMotion(event: MotionEvent): Boolean {
if (event.actionMasked != MotionEvent.ACTION_MOVE) return true
val sticks = physical.getOrPut(event.deviceId) { Sticks() }
val wired = wiredLayout.getOrPut(event.deviceId) { PadBridge.wiredXboxLayout(event) }
sticks.leftX = event.getAxisValue(MotionEvent.AXIS_X)
sticks.leftY = event.getAxisValue(MotionEvent.AXIS_Y)
sticks.rightX = PadBridge.rightX(event, wired)
sticks.rightY = PadBridge.rightY(event, wired)
if (hasTriggerAxes(event)) {
sticks.leftTrigger = PadBridge.leftTrigger(event, wired)
sticks.rightTrigger = PadBridge.rightTrigger(event, wired)
}
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
val hat = source(event.deviceId, SOURCE_HAT)
set(hat, UP, hatY < -0.5f)
set(hat, DOWN, hatY > 0.5f)
set(hat, LEFT, hatX < -0.5f)
set(hat, RIGHT, hatX > 0.5f)
wake()
return true
}
fun onTouchPad(state: PadState) {
touch.leftX = state.leftX
touch.leftY = state.leftY
touch.rightX = state.rightX
touch.rightY = state.rightY
touch.leftTrigger = state.leftTrigger
touch.rightTrigger = state.rightTrigger
for (button in TOUCH_BUTTONS) {
set(SOURCE_TOUCH, button, state.isDown(button))
}
set(SOURCE_TOUCH, UP, state.up)
set(SOURCE_TOUCH, DOWN, state.down)
set(SOURCE_TOUCH, LEFT, state.left)
set(SOURCE_TOUCH, RIGHT, state.right)
wake()
}
fun reset() {
stop()
physical.clear()
triggerAxes.clear()
wiredLayout.clear()
touch.clear()
down.clear()
sent.clear()
stickDirection = NONE
scrolling = false
held.keys.toList().reversed().forEach { code ->
held.remove(code)
send(code, false)
}
}
internal fun step(dt: Float) {
sum()
val lx = total.leftX
val ly = total.leftY
val held = heldDirection()
val direction = when {
held != NONE -> held
hypot(lx, ly) < STICK_NAVIGATE -> NONE
abs(lx) > abs(ly) -> if (lx > 0) RIGHT else LEFT
else -> if (ly > 0) DOWN else UP
}
val now = clock()
if (direction != stickDirection) {
stickDirection = direction
if (direction != NONE) {
tap(direction)
stickRepeatAt = now + REPEAT_FIRST_MS
}
} else if (direction != NONE && now >= stickRepeatAt) {
tap(direction)
stickRepeatAt = now + REPEAT_MS
}
val gain = gain(total.rightX, total.rightY)
val sx = total.rightX * gain
val triggers = trigger(total.rightTrigger) - trigger(total.leftTrigger)
val vertical = (total.rightY * gain + triggers).coerceIn(-1f, 1f)
if (sx != 0f || vertical != 0f) {
if (!scrolling) {
scrolling = true
listener.onScrollStart()
}
val speed = listener.scrollSpeed() * dt
listener.onScroll(sx * speed, vertical * speed)
} else if (scrolling) {
scrolling = false
press(KEY_SETTLE)
release(KEY_SETTLE)
}
}
private fun set(source: Int, button: Int, pressed: Boolean) {
val sources = down.getOrPut(button) { HashSet() }
val was = sources.isNotEmpty()
if (pressed) sources.add(source) else sources.remove(source)
val now = sources.isNotEmpty()
if (was == now) return
if (button in DIRECTIONS) {
if (now) step(0f)
wake()
return
}
if (now) {
if (button == PadState.Y) {
listener.onKeyboard()
return
}
val codes = codesFor(button)
sent[button] = codes
codes.forEach { press(it) }
} else {
sent.remove(button)?.reversed()?.forEach { release(it) }
}
}
private fun hasTriggerAxes(event: android.view.InputEvent): Boolean = triggerAxes.getOrPut(event.deviceId) {
val device = event.device ?: return@getOrPut true
device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || device.getMotionRange(MotionEvent.AXIS_BRAKE) != null ||
(event is MotionEvent && wiredLayout.getOrPut(event.deviceId) { PadBridge.wiredXboxLayout(event) })
}
private fun heldDirection(): Int {
for (direction in DIRECTIONS) if (down[direction]?.isNotEmpty() == true) return direction
return NONE
}
private fun sum() {
total.leftX = touch.leftX
total.leftY = touch.leftY
total.rightX = touch.rightX
total.rightY = touch.rightY
total.leftTrigger = touch.leftTrigger
total.rightTrigger = touch.rightTrigger
for (sticks in physical.values) {
total.leftX += sticks.leftX
total.leftY += sticks.leftY
total.rightX += sticks.rightX
total.rightY += sticks.rightY
total.leftTrigger += sticks.leftTrigger
total.rightTrigger += sticks.rightTrigger
}
}
private fun tap(direction: Int) {
listener.onNavigate()
val codes = codesFor(direction)
codes.forEach { press(it) }
codes.reversed().forEach { release(it) }
}
private fun codesFor(button: Int): IntArray = when (button) {
PadState.A -> intArrayOf(KEY_ENTER)
PadState.X -> intArrayOf(KEY_MENU)
PadState.B -> intArrayOf(KEY_ESC, KEY_BACK)
PadState.START -> intArrayOf(KEY_ENTER)
PadState.LB -> intArrayOf(KEY_LEFTCTRL, KEY_PAGEUP)
PadState.RB -> intArrayOf(KEY_LEFTCTRL, KEY_PAGEDOWN)
UP -> intArrayOf(KEY_UP)
DOWN -> intArrayOf(KEY_DOWN)
LEFT -> intArrayOf(KEY_LEFT)
RIGHT -> intArrayOf(KEY_RIGHT)
else -> IntArray(0)
}
private fun wake() {
if (moving() && !running) {
running = true
frameAt = 0L
choreographer.postFrameCallback(frame)
}
}
private fun moving(): Boolean {
if (heldDirection() != NONE || stickDirection != NONE || scrolling) return true
sum()
return hypot(total.leftX, total.leftY) >= STICK_NAVIGATE || gain(total.rightX, total.rightY) != 0f ||
trigger(total.leftTrigger) != 0f || trigger(total.rightTrigger) != 0f
}
private fun stop() {
running = false
frameAt = 0L
choreographer.removeFrameCallback(frame)
}
private fun press(code: Int) {
val count = held[code] ?: 0
held[code] = count + 1
if (count == 0) send(code, true)
}
private fun release(code: Int) {
val count = held[code] ?: return
if (count > 1) {
held[code] = count - 1
return
}
held.remove(code)
send(code, false)
}
private fun send(code: Int, pressed: Boolean) {
listener.onKey(code, pressed)
}
companion object {
private const val DEAD_ZONE = 0.15f
private const val STICK_NAVIGATE = 0.5f
private const val REPEAT_FIRST_MS = 300L
private const val REPEAT_MS = 120L
private const val SOURCE_KEYS = 0
private const val SOURCE_HAT = 1
private const val SOURCE_TOUCH = Int.MIN_VALUE
private const val NONE = -1
private const val UP = 100
private const val DOWN = 101
private const val LEFT = 102
private const val RIGHT = 103
private val DIRECTIONS = intArrayOf(UP, DOWN, LEFT, RIGHT)
private val TOUCH_BUTTONS = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB, PadState.START)
private const val KEY_ESC = 1
private const val KEY_ENTER = 28
private const val KEY_UP = 103
private const val KEY_LEFT = 105
private const val KEY_RIGHT = 106
private const val KEY_DOWN = 108
private const val KEY_BACK = 158
private const val KEY_LEFTCTRL = 29
private const val KEY_PAGEUP = 104
private const val KEY_PAGEDOWN = 109
private const val KEY_MENU = 127
private const val KEY_SETTLE = 183
private val BUTTONS = mapOf(
KeyEvent.KEYCODE_BUTTON_A to PadState.A,
KeyEvent.KEYCODE_BUTTON_B to PadState.B,
KeyEvent.KEYCODE_BUTTON_X to PadState.X,
KeyEvent.KEYCODE_BUTTON_Y to PadState.Y,
KeyEvent.KEYCODE_BUTTON_L1 to PadState.LB,
KeyEvent.KEYCODE_BUTTON_R1 to PadState.RB,
KeyEvent.KEYCODE_BUTTON_START to PadState.START,
KeyEvent.KEYCODE_DPAD_UP to UP,
KeyEvent.KEYCODE_DPAD_DOWN to DOWN,
KeyEvent.KEYCODE_DPAD_LEFT to LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT to RIGHT,
)
internal fun curve(x: Float, y: Float): Pair<Float, Float> {
val gain = gain(x, y)
return x * gain to y * gain
}
private fun gain(x: Float, y: Float): Float {
val length = hypot(x, y)
if (length <= DEAD_ZONE) return 0f
val scaled = (length.coerceAtMost(1f) - DEAD_ZONE) / (1f - DEAD_ZONE)
return scaled * scaled / length
}
private fun source(deviceId: Int, kind: Int): Int = deviceId * 2 + kind
private fun trigger(value: Float): Float =
if (value <= DEAD_ZONE) 0f else (value.coerceAtMost(1f) - DEAD_ZONE) / (1f - DEAD_ZONE)
}
}
@@ -0,0 +1,190 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.MotionEvent
import android.view.VelocityTracker
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.hypot
import kotlin.math.pow
class ScrollTouchGestures(
private val slopPx: Float,
private val minFlingPx: Float,
private val listener: Listener,
) {
interface Listener {
fun onMove(x: Float, y: Float)
fun onButton(button: Int, pressed: Boolean)
fun onScroll(dx: Float, dy: Float)
fun onLongPress()
}
private val handler = Handler(Looper.getMainLooper())
private val choreographer = Choreographer.getInstance()
private var tracker: VelocityTracker? = null
private var pointerId = -1
private var startX = 0f
private var startY = 0f
private var lastX = 0f
private var lastY = 0f
private var movedAt = 0L
private var scrolling = false
private var vertical = true
private var held = false
private var dragging = false
private var stoppedFling = false
private var clicked = 0
private var flingX = 0f
private var flingY = 0f
private var flingAt = 0L
private var flinging = false
private val longPress = Runnable {
held = true
listener.onLongPress()
}
private val clickUp = Runnable { releaseClick() }
private val flingFrame = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!flinging) return
val dt = if (flingAt == 0L) 1f / 60f else ((frameTimeNanos - flingAt) / 1e9f).coerceIn(0f, 0.05f)
flingAt = frameTimeNanos
val decay = exp(-dt / FLING_TIME_S)
listener.onScroll(flingX * dt, flingY * dt)
flingX *= decay
flingY *= decay
if (hypot(flingX, flingY) < FLING_STOP_PX) flinging = false else choreographer.postFrameCallback(this)
}
}
fun cancel() {
handler.removeCallbacks(longPress)
stopFling()
releaseClick()
if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false)
dragging = false
held = false
scrolling = false
pointerId = -1
tracker?.recycle()
tracker = null
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
stoppedFling = flinging
cancel()
pointerId = event.getPointerId(0)
startX = event.x
startY = event.y
lastX = startX
lastY = startY
tracker = VelocityTracker.obtain().also { it.addMovement(event) }
listener.onMove(startX, startY)
handler.postDelayed(longPress, HOLD_MS)
}
MotionEvent.ACTION_MOVE -> {
val index = event.findPointerIndex(pointerId).takeIf { it >= 0 } ?: return true
tracker?.addMovement(event)
val x = event.getX(index)
val y = event.getY(index)
if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) {
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true)
} else {
scrolling = true
vertical = abs(y - startY) >= abs(x - startX)
lastX = startX
lastY = startY
}
}
if (dragging) listener.onMove(x, y)
follow(event, x, y)
}
MotionEvent.ACTION_POINTER_UP -> {
if (event.getPointerId(event.actionIndex) == pointerId) cancel()
}
MotionEvent.ACTION_UP -> {
handler.removeCallbacks(longPress)
if (pointerId == -1 || event.getPointerId(0) != pointerId) return true
tracker?.addMovement(event)
follow(event, event.x, event.y)
when {
scrolling -> tracker?.takeIf { event.eventTime - movedAt <= FLING_STILL_MS }?.let { t ->
t.computeCurrentVelocity(1000)
val vx = if (vertical) 0f else -t.getXVelocity(pointerId)
val vy = if (vertical) -t.getYVelocity(pointerId) else 0f
if (hypot(vx, vy) >= minFlingPx) startFling(vx, vy)
}
dragging -> listener.onButton(PointerGestures.BTN_LEFT, false)
held -> click(PointerGestures.BTN_RIGHT)
!stoppedFling -> click(PointerGestures.BTN_LEFT)
}
dragging = false
held = false
scrolling = false
pointerId = -1
tracker?.recycle()
tracker = null
}
MotionEvent.ACTION_CANCEL -> cancel()
}
return true
}
private fun follow(event: MotionEvent, x: Float, y: Float) {
if (!scrolling || (x == lastX && y == lastY)) return
movedAt = event.eventTime
if (vertical) listener.onScroll(0f, lastY - y) else listener.onScroll(lastX - x, 0f)
lastX = x
lastY = y
}
private fun click(button: Int) {
releaseClick()
listener.onButton(button, true)
clicked = button
handler.postDelayed(clickUp, CLICK_MS)
}
private fun releaseClick() {
handler.removeCallbacks(clickUp)
if (clicked != 0) listener.onButton(clicked, false)
clicked = 0
}
private fun startFling(vx: Float, vy: Float) {
flingX = vx
flingY = vy
flingAt = 0L
flinging = true
choreographer.postFrameCallback(flingFrame)
}
private fun stopFling() {
flinging = false
choreographer.removeFrameCallback(flingFrame)
}
companion object {
fun wheelUnits(pixels: Int, viewSize: Int): Int {
if (pixels == 0) return 0
val step = floor(viewSize.toDouble().pow(2.0 / 3.0)).coerceAtLeast(1.0)
val units = (abs(pixels) * 120.0 / step).toInt() + 1
return if (pixels < 0) -units else units
}
private const val HOLD_MS = 500L
private const val CLICK_MS = 50L
private const val FLING_TIME_S = 0.45f
private const val FLING_STOP_PX = 40f
private const val FLING_STILL_MS = 60L
}
}
@@ -108,11 +108,11 @@ object DeckyManager {
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "PluginLoader.download")
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
val ok = Downloader.downloadFile(release.url, temp, true, release.digest?.substringAfter(':')) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
val checksumBody = Downloader.downloadDirect(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
@@ -236,7 +236,7 @@ object DeckyManager {
downloadingBinary = true
progress(downloading, -1)
val downloaded = try {
Downloader.downloadFile(url, temp, false) { fraction ->
Downloader.downloadFile(url, temp, true, hash) { fraction ->
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}
} finally {
@@ -90,7 +90,7 @@ object DesktopCatalog {
try {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true) { f ->
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
@@ -176,6 +176,7 @@ object FlatpakManager {
return id to problem
}
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
fun prune(context: Context, onProgress: (String, Int) -> Unit) = transaction(context, "prune", null, onProgress)
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
@@ -261,7 +261,7 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
fraction < 0 ? -1 : Math.round(fraction * 100f));
Loaded 100 of 410 files, more files were not shown because too many files have changed in this diff. Show more