Compare commits

...
Author SHA1 Message Date
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
Kurt Himebauch d301148881 fix: keep SteamVR syscall hooks and report Steam crashes (#905)
* fix: preserve SteamVR syscall hooks

* fix: report Steam crashes through gamescope
2026-10-15 15:50:26 +00:00
Jagger15andxXJsonDeruloXx 787cec2548 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]>
2026-10-15 09:51:02 -04:00
Jamie Schenker 0a72690631 fix: keep proot sessions alive when the kernel loses fork events (#457) 2026-10-15 09:50:29 -04:00
Muaz Kadan b539cc1111 Fix deploy_local.sh and droiddeckctl not finding a phone connected over wireless debugging (#864) 2026-10-15 09:47:01 -04:00
Kurt Himebauch acf66b2b0e fix: keep Quest-patched Turnip as the auto default (#853) 2026-10-15 09:46:26 -04:00
Kurt Himebauch 892f400145 feat: three-finger tap toggles Steam's on-screen keyboard in game mode (#782) 2026-10-15 09:36:28 -04:00
kingokksa 47e4f25346 feat: sort the store library and installed tabs, and switch between card and list views (#798) 2026-10-15 09:20:49 -04:00
Muaz Kadan e1ca869d8e Fix local builds on Linux failing after the first Comet build (#866) 2026-10-15 09:20:03 -04:00
Resul Övür 9eb509b9e1 test: cover GPU family and support classification (#880) 2026-10-15 09:19:40 -04:00
Kurt Himebauch c39a521a28 feat: Steam home wall fills from the signed-in library instead of store picks (#878) 2026-10-15 09:18:07 -04:00
FX1922 58ab106da8 fix: SteamGridDB lookups no longer stick as "no match" after a failure; add a search to the art chooser (#876) 2026-10-15 08:36:48 -04:00
Kurt Himebauch 6e49c0c287 feat: low-latency controller and touch toggles in the session drawer (#808) 2026-10-15 07:53:25 -04:00
The412Banner 322040123d DroidDeck-Proton: get releases from the Droid-Deck org repo (#875) 2026-10-15 07:29:42 -04:00
The412Banner 36a9cbfc13 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.
2026-10-15 07:20:09 -04:00
Kurt Himebauch a2486b2afc fix: dependency scan ignores folders above the game folder (#859) 2026-10-15 07:03:17 -04:00
asdf17128 1bd3c64d01 Add top, bottom, and centered picture positions for Steam sessions (#871) 2026-10-15 07:01:01 -04:00
7fc61b4c3d 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]>
2026-10-15 03:41:17 +00:00
asdf17128andsiyuan.qsy 21e5fdfc63 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]>
2026-10-14 23:37:35 -04:00
Resul Övürandovurrsl f819988b0d 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]>
2026-10-14 23:30:19 -04:00
39c8a0fe8c 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]>
2026-10-15 02:18:26 +00:00
Kurt Himebauchandangelrenefm 44331558cc 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]>
2026-10-15 02:17:06 +00:00
Kurt Himebauchandangelrenefm d2bab8970d 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]>
2026-10-14 22:15:29 -04:00
eb22557702 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]>
2026-10-14 22:09:50 -04:00
Resul Övürandovurrsl 38d560cef1 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]>
2026-10-14 22:01:29 -04:00
KirkandKirk 45a808c80d 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]>
2026-10-14 22:01:17 -04:00
Resul Övürandovurrsl 929e049761 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]>
2026-10-14 22:00:54 -04:00
Resul Övür 684f7407de fix: wireless ADB page names the child-process switch the way Android shows it (#848) 2026-10-14 21:45:18 -04:00
Yassine GherbiandKurt Himebauch 00a106e2fa chore: add agent comment rules (#748)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-14 21:39:47 -04:00
Kurt Himebauch 91dabdc1e7 chore: keep only the 3 newest session log folders (#852) 2026-10-14 21:32:05 -04:00
Kurt Himebauch f635e92498 fix: Steam touch controls load the layout Steam picked for DroidDeck's touch controller, including workshop layouts (#840) 2026-10-14 16:09:40 -04:00
RaidennrlandxXJsonDeruloXx eda1e9b9c6 Fix wired PowerA and PDP right-stick and trigger mapping (#692)
Fix wired PowerA and PDP stick and trigger axes

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-14 13:56:17 -04:00
kmnkv1andxXJsonDeruloXx fa9a9446bb Build and test on Windows: LF session scripts, '/' in relative paths (#552)
fix: build and test on Windows

- .gitattributes keeps the session's scripts LF under core.autocrlf=true;
  CRLF scripts made every session exit with status 2.
- runtime_inputs.py and the scrub record / log zip use '/' in relative
  paths, which Windows reported with '\' (runtime inputs never matched).
- The read-only-folder scrub test is skipped on Windows, which still
  creates files in a read-only directory.

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-14 13:56:12 -04:00
Gustash 3e3c4d69e4 feat: hide unfolded controls when a controller is connected (#829)
* feat: hide unfolded controls when a controller is connected

* fix: also hide controls when a BLE controller is connected

* fix: use refreshControls on session resume as well
2026-10-14 13:52:13 -04:00
Tjebbe Kerstens 913806f169 fix: seed redist markers again when the registry lost them (#793)
* fix: redist markers a replaced registry lost are seeded again

droiddeck-seed-redists recorded what it seeded in .droiddeck-redists and
trusted that record from then on. A Proton update replaces its
default_pfx system.reg but leaves our record beside it, and every new
prefix copies both, so new games got a record of markers their registry
never had: Steam then ran their install scripts for real. Batman: Arkham
City's AMD Dual-Core Optimizer installer hangs there, leaving the game
on "Running install script" on every launch.

Each recorded marker is now looked up in the registry, in both views
where both were written, and seeded again when it is gone; a record of
markers nobody wants any more is rewritten without them.

* Check every VC runtime architecture before keeping the vcrt marker

vcrt writes Installed for x86, x64 and arm64 in both registry views, but
present() only looked at x86. If only an x64 or arm64 value was gone the
runtime would not be seeded again. Require all six values and test the
loss of the arm64 Wow6432Node one.
2026-10-14 13:52:07 -04:00
Tjebbe Kerstens 7b018e6c42 fix: Batman: Arkham City starts with full x87 precision under FEX (#792)
Arkham City (BatmanAC.exe) stopped with Unreal's "Fatal error!" about
1.5 s after start: an access violation in BatmanAC.exe right after
steam.dll loads. The game leans on x87 and needs full precision under
FEX (FEX wiki), while the default FEX preset sets
FEX_X87REDUCEDPRECISION=1. Engine fixes sit above the shared preset and
below the player's own game profile, so a fix keyed on BatmanAC.exe
beside the started exe (BatmanAC.exe or BmLauncher.exe) restores
x87 precision for this game only. Arkham Asylum, which shares
BmLauncher.exe but not BatmanAC.exe, keeps the preset.
2026-10-14 13:52:01 -04:00
KirkandKirk e45d6f7192 fix: game shortcuts use the cover art when Steam's icon is too small (#825)
Steam keeps a 32x32 icon for each game, and the home screen stretched
it to launcher size. A square from the middle of the cover art is used
instead unless the icon is at least 128 px; the small icon is still
the fallback when there is no art.

Co-authored-by: Kirk <[email protected]>
2026-10-14 13:51:56 -04:00
Muaz Kadan b3ff93e80f Fix battery time estimates showing thousands of hours on phones that report current in mA (#820)
fix: read battery current reported in mA so time estimates aren't 1000x too long
2026-10-14 13:51:51 -04:00
William AllenandClaude Opus 5.5 c035351b4f fix: read the replaced listing's watch descriptor before detach can free it (#685)
install() replaces an existing cache entry by calling detach(old), which
destroys the listing when no reader holds it, and then reads old->wd to
decide whether to release the watch. When the old listing has no readers,
that read happens after the free.

Two threads that enumerate the same uncached directory at the same time
reach this path: both build a listing, the first installs an entry with
no readers, and the second replaces it.

Save the watch descriptor before detaching, as drop() already does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:51:44 -04:00
Resul Övürandovurrsl 10cdf97480 fix: Turkish translation for zRAM, reset-settings and store storage messages (#826)
Adds the 14 translatable strings that values-tr was missing: the zRAM toggle and its hints, the Reset all settings row, dialog and toasts, and the store install free-space messages. Keys marked translatable=false stay untranslated on purpose.

Co-authored-by: ovurrsl <[email protected]>
2026-10-14 13:51:38 -04:00
Resul Övürandovurrsl f614483d01 docs: README names the child-process setting the way Android shows it (#831)
* docs: README names the child-process setting the way Android shows it

Developer options label this switch "Disable child process restrictions", and it has to be turned on. The README said to turn off "Restrict child processes".

* docs: README limits the child-process toggle to Android 14 and newer

The Developer options switch only exists from Android 14 (PhantomProcessLimit.hasDeveloperToggle); Android 12 and 13 keep the Fix it for me flow.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-14 13:51:34 -04:00
Imgau f5eb5e7b81 fix: declare all-files access for raw USB storage (#760) 2026-10-14 13:48:14 -04:00
Kurt Himebauch bf99dbbe73 feat: store sign-in walls and the Steam home wall fill with curated featured art (#835) 2026-10-14 13:42:27 -04:00
Kushagra 46fac69438 feat: map a controller's two back paddles to the Deck's L4 and R4 (#797) 2026-10-14 15:31:59 +00:00
Gustash 82f04fb22e fix: the touchpad moves a game's own cursor when it locks the pointer (#772) 2026-10-14 11:30:54 -04:00
Kurt Himebauch 9796525449 perf: local builds reuse unchanged native steps across runs and worktrees (#819) 2026-10-14 15:30:24 +00:00
267 changed files with 17283 additions and 1339 deletions

No files matched your search

+2
View File
@@ -1,2 +1,4 @@
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
+26 -2
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 }}-
+3
View File
@@ -0,0 +1,3 @@
# Rules
- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
+2 -2
View File
@@ -17,7 +17,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
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.
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
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.
## Community
@@ -37,4 +37,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. 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.
+4 -2
View File
@@ -150,6 +150,7 @@ android {
}
buildFeatures {
prefab true
compose true
buildConfig true
}
@@ -225,7 +226,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')
@@ -244,5 +246,5 @@ dependencies {
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'
}
+22 -1
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -21,6 +22,7 @@
<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.vr.headtracking" android:version="1" android:required="false" />
<application
android:name=".App"
@@ -36,20 +38,35 @@
android:isGame="true">
<meta-data android:name="android.game_mode_config" android:resource="@xml/game_mode_config" />
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s" />
<activity android:name=".QuestXrActivity" android:exported="true" android:launchMode="singleTask"
android:permission="android.permission.DUMP"
android:resizeableActivity="false" android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<meta-data android:name="android.app.lib_name" android:value="droiddeckxr" />
<intent-filter>
<action android:name="com.droiddeck.launcher.XR" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|navigation">
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
<data android:scheme="droiddeck" android:host="game" />
</intent-filter>
<intent-filter>
@@ -59,6 +76,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>
@@ -91,18 +109,21 @@
android:name=".files.FileManagerActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".files.FilePickerActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
+1
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)
+57 -39
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);
}
@@ -1140,8 +1143,12 @@ struct DeckImu {
uint16_t pressure[2];
uint32_t controls;
uint32_t pad_type;
uint32_t latency;
};
static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter");
static_assert(offsetof(DeckImu, latency) == 48, "DeckImu::latency matches FakeInputWriter");
static constexpr uint32_t LATENCY_CONTROLLER = 1, LATENCY_TOUCH = 2;
static constexpr int LOW_LATENCY_REPORT_INTERVAL_US = 1000;
static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4,
DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16,
DECK_EXTRA_RPAD_TOUCH = 32, DECK_EXTRA_LPAD_CLICK = 64,
@@ -1303,6 +1310,27 @@ __attribute__((visibility("hidden"))) static uint32_t live_pad_type() {
return value & PAD_TYPE_SET ? value & 0xff : session_pad_type();
}
__attribute__((visibility("hidden"))) static bool low_latency(uint32_t which) {
const uint8_t *blocks = ring_blocks();
const DeckImu *imu = blocks ? reinterpret_cast<const DeckImu *>(blocks + FAKE_INPUT_RING_SIZE) : nullptr;
return imu && (__atomic_load_n(&imu->latency, __ATOMIC_ACQUIRE) & which);
}
__attribute__((visibility("hidden"))) static int controller_report_interval_us(int normal_us) {
return low_latency(LATENCY_CONTROLLER) ? LOW_LATENCY_REPORT_INTERVAL_US : normal_us;
}
__attribute__((visibility("hidden"))) static void sleep_us(int us) {
struct timespec interval = {0, us * 1000L};
nanosleep(&interval, nullptr);
}
__attribute__((visibility("hidden"))) static uint64_t monotonic_us() {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return static_cast<uint64_t>(now.tv_sec) * 1000000ULL + static_cast<uint64_t>(now.tv_nsec) / 1000ULL;
}
static bool fake_triton_enabled() {
static int enabled = -1;
if (enabled < 0)
@@ -1453,8 +1481,7 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
munmap(self->ring, self->mapping_size);
@@ -1486,7 +1513,7 @@ __attribute__((visibility("hidden"))) static void put_sony_touch(uint8_t *at, So
}
__attribute__((visibility("hidden"))) static void
build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t buttons, const int32_t *axes,
build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes,
const DeckExtras &extras, SonyTouch &touch) {
memset(report, 0, SONY_REPORT_BYTES);
report[0] = 0x01;
@@ -1517,7 +1544,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t butt
p[6] = static_cast<uint8_t>(system | ((packet & 0x3f) << 2));
p[7] = left_trigger;
p[8] = right_trigger;
put16(p + 9, static_cast<int>(packet * 750));
put16(p + 9, static_cast<int>(elapsed_us * 3 / 16));
for (int i = 0; i < 3; i++) {
put16(p + 12 + i * 2, gyro[i]);
put16(p + 18 + i * 2, accel[i]);
@@ -1539,7 +1566,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t butt
put16(p + 15 + i * 2, gyro[i]);
put16(p + 21 + i * 2, accel[i]);
}
put32(p + 27, packet * 12000);
put32(p + 27, static_cast<uint32_t>(elapsed_us * 3));
put_sony_touch(p + 32, touch, 0, left, extras.pads[0], extras.pads[1], SONY_TOUCH_HEIGHT_DUALSENSE);
put_sony_touch(p + 36, touch, 1, right, extras.pads[2], extras.pads[3], SONY_TOUCH_HEIGHT_DUALSENSE);
p[52] = 0x2a;
@@ -1558,6 +1585,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
int32_t axes[8] = {};
DeckExtras extras;
SonyTouch touch;
const uint64_t start_us = monotonic_us();
while (live_pad_type() == type) {
if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
buttons = snap.buttons;
@@ -1565,12 +1593,11 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
}
read_deck_extras(*self, extras);
uint8_t report[SONY_REPORT_BYTES];
build_sony_report(report, self->sony, ++packet, buttons, axes, extras, touch);
build_sony_report(report, self->sony, ++packet, monotonic_us() - start_us, buttons, axes, extras, touch);
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
munmap(self->ring, self->mapping_size);
@@ -1684,8 +1711,7 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar
errno != EAGAIN && errno != EWOULDBLOCK)
break;
}
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
munmap(self->ring, self->mapping_size);
@@ -1709,7 +1735,7 @@ static constexpr int16_t TRITON_WIRED_PRODUCT = 0x1302;
static constexpr int32_t TRITON_STICK_TOUCH = 2000;
__attribute__((visibility("hidden"))) static void
build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes,
build_triton_report(uint8_t *report, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes,
const DeckExtras &extras) {
memset(report, 0, TRITON_STATE_BYTES);
report[0] = static_cast<uint8_t>(packet);
@@ -1752,7 +1778,7 @@ build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const in
put16(report + 23, extras.pads[2]);
put16(report + 25, extras.pads[3]);
put16(report + 27, extras.pressure[1]);
put32(report + 29, packet * TRITON_REPORT_INTERVAL_US);
put32(report + 29, static_cast<uint32_t>(elapsed_us));
for (int i = 0; i < 3; i++) {
put16(report + 33 + i * 2, extras.accel[i]);
put16(report + 39 + i * 2, extras.gyro[i]);
@@ -1768,6 +1794,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
uint32_t buttons = 0;
int32_t axes[8] = {};
DeckExtras extras;
const uint64_t start_us = monotonic_us();
while (live_pad_type() == PAD_TYPE_STEAM) {
if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
buttons = snap.buttons;
@@ -1776,12 +1803,11 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
read_deck_extras(*self, extras);
uint8_t report[TRITON_REPORT_ID_BYTES + TRITON_STATE_BYTES];
report[0] = TRITON_STATE_WIRED;
build_triton_report(report + 1, ++packet, buttons, axes, extras);
build_triton_report(report + 1, ++packet, monotonic_us() - start_us, buttons, axes, extras);
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
munmap(self->ring, self->mapping_size);
@@ -2019,8 +2045,7 @@ __attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
sleep_us(low_latency(LATENCY_TOUCH) ? LOW_LATENCY_REPORT_INTERVAL_US : TOUCH_REPORT_INTERVAL_US);
}
syscall(SYS_close, self->peer);
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
@@ -3447,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;
@@ -3545,6 +3561,7 @@ EXPORT int close(int fd) {
}
static constexpr int kMaxRingWaitMs = 2;
static constexpr int kLowLatencyRingWaitUs = 250;
EXPORT ssize_t read(int fd, void *buf, size_t count) {
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
@@ -3633,7 +3650,8 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
int result = nanosleep(&sleep_time, nullptr);
guard.lock();
if (result < 0) return -1;
if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
if (low_latency(LATENCY_CONTROLLER)) backoff_ns = kLowLatencyRingWaitUs * 1000;
else if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
}
}
@@ -3815,7 +3833,7 @@ static int poll_fake(struct pollfd *fds, nfds_t nfds, int timeout,
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
return 0;
if (backoff_ms < kMaxRingWaitMs)
if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER))
backoff_ms *= 2;
}
}
@@ -3988,7 +4006,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
return 0;
if (backoff_ms < kMaxRingWaitMs)
if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER))
backoff_ms *= 2;
}
}
+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);
}
}
+60 -3
View File
@@ -657,6 +657,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 +741,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; }
@@ -1871,7 +1874,12 @@ static void render_scene(void) {
else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0);
else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver";
}
if (hdr_route == 0 || hdr_route == 3) {
if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard &&
!ahb_layer_only_candidate(w, h)) {
/* The complete SDR scene stays on one display layer. Do not hide/re-show GAME
* on each desktop frame just because it is not a single fullscreen window. */
rendered = vkp_render(w, h, dl.d, dl.n) == 0;
} else if (hdr_route == 0 || hdr_route == 3) {
const int pass_on = fx_on || framegen;
int li = (g_zero_copy && !framegen && !cardboard) ? layer_candidate(&dl, w, h) : -1;
int over = li >= 0 ? dl.n - 1 - li : 0;
@@ -2194,6 +2202,7 @@ struct constraint {
int pending_region_set;
int hint_set, pending_hint_set;
double hint_x, hint_y, pending_hint_x, pending_hint_y;
int gave_keys;
};
static struct wl_list g_constraints;
static struct constraint *g_active_constraint;
@@ -2272,6 +2281,14 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
g_ptr_y = oy + k->hint_y;
}
g_raw_valid = 0;
if (k->gave_keys) {
k->gave_keys = 0;
for (int i = 0; i < g_nkbs; i++) {
if (!k->surface || g_kbs[i].focus != k->surface->resource) continue;
wl_keyboard_send_leave(g_kbs[i].kb, wl_display_next_serial(g_display), g_kbs[i].focus);
g_kbs[i].focus = NULL;
}
}
if (tell_client) constraint_send_state(k, 0);
constraint_describe(k, name, sizeof(name));
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
@@ -2290,6 +2307,12 @@ static void constraint_activate(struct constraint *k) {
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
constraint_origin(k, &ox, &oy);
pointer_focus(k->surface->resource, wl_fixed_from_double(g_ptr_x - ox), wl_fixed_from_double(g_ptr_y - oy));
int focused = 0;
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus) focused = 1;
if (k->is_lock && !focused && keyboard_for(wl_resource_get_client(k->surface->resource))) {
keyboard_focus(k->surface->resource);
k->gave_keys = 1;
}
constraint_send_state(k, 1);
constraint_describe(k, name, sizeof(name));
if (k->is_lock)
@@ -2594,7 +2617,24 @@ static void pointer_delta(double dx, double dy) {
}
static void pointer_button(uint32_t button, int pressed) {
pointer_input(0, 0, 1, button, pressed);
if (pressed) {
pointer_input(0, 0, 1, button, pressed);
return;
}
// Releases also clean up interrupted gestures. Deliver them to the existing
// pointer focus without motion or hit-testing: native touch has independent
// coordinates, so replaying g_ptr_x/y here can warp the game cursor.
uint32_t t = now_ms();
for (int i = 0; i < g_nptrs; i++) {
struct seat_pointer *sp = &g_ptrs[i];
if (!sp->focus) continue;
wl_pointer_send_button(sp->ptr, wl_display_next_serial(g_display), t, button,
WL_POINTER_BUTTON_STATE_RELEASED);
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
wl_pointer_send_frame(sp->ptr);
}
g_grab = NULL;
wl_display_flush_clients(g_display);
}
static void deliver_pointer(const struct input_msg *m) {
@@ -2901,12 +2941,26 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break;
case 7: deliver_touch(&m); break;
case 8: output_resized(); break;
case 9:
vk_present_set_scale_mode(m.p1, m.p2);
schedule_render();
break;
default: deliver_pointer(&m); break;
}
}
return 0;
}
void droiddeck_wayland_set_scale_mode(int mode, int alignment) {
if (g_input_pipe[1] < 0) {
vk_present_set_scale_mode(mode, alignment);
return;
}
struct input_msg m = { 9, mode, alignment, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n;
}
void droiddeck_wayland_send_pointer(int action, int x, int y) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 0, action, x, y, 0 };
@@ -3110,6 +3164,9 @@ int droiddeck_wayland_run(void) {
open_session_log();
if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn");
/* Presentation compatibility is not HDR capability. Set the initial layer mode before
* clients bind, including when Desktop is the first session in this app process. */
if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1;
struct wl_display *display = wl_display_create();
if (!display) {
@@ -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;
@@ -0,0 +1,13 @@
#ifndef DROIDDECK_QUEST_DEVICE_H
#define DROIDDECK_QUEST_DEVICE_H
#include <string.h>
#include <strings.h>
/* Presentation only; this does not select a GPU driver or expand driver support. */
static inline int quest_device_matches(const char *model, const char *device, const char *maker) {
return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") ||
!strcasecmp(model, "Quest 3S") ||
((!strcasecmp(maker, "Oculus") || !strcasecmp(maker, "Meta")) &&
(!strcmp(device, "hollywood") || !strcmp(device, "eureka") || !strcmp(device, "panther")));
}
#endif
+51 -4
View File
@@ -185,6 +185,8 @@ static int g_pending_cb;
static unsigned g_stat_layer_frames;
static int g_two_logged;
static AHardwareBuffer *g_blank;
static ASurfaceControl *g_base_sc;
static int g_base_w, g_base_h, g_base_shown;
static void layers_init(void) {
if (g_layers_ready) return;
@@ -262,14 +264,58 @@ static AHardwareBuffer *blank_buffer(void) {
AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY};
if (AHardwareBuffer_allocate(&d, &g_blank) != 0 || !g_blank) { g_blank = NULL; return NULL; }
void *p = NULL;
if (AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p) == 0 && p) {
AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got);
memset(p, 0, (size_t)got.stride * 4 * 16);
AHardwareBuffer_unlock(g_blank, NULL);
int locked = AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p);
if (locked != 0 || !p) {
if (locked == 0) AHardwareBuffer_unlock(g_blank, NULL);
AHardwareBuffer_release(g_blank); g_blank = NULL;
return NULL;
}
AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got);
memset(p, 0, (size_t)got.stride * 4 * got.height);
if (AHardwareBuffer_unlock(g_blank, NULL) != 0) {
AHardwareBuffer_release(g_blank); g_blank = NULL;
return NULL;
}
return g_blank;
}
int sc_layer_base_black(int width, int height) {
if (width <= 0 || height <= 0 || load_api() != 0 || !vkp_window()) return -1;
if (g_base_sc && g_base_shown && width == g_base_w && height == g_base_h) return 0;
AHardwareBuffer *black = blank_buffer();
if (!black) return -1;
if (!g_base_sc) g_base_sc = api.createFromWindow(vkp_window(), "droiddeck_wayland_black");
if (!g_base_sc) return -1;
ASurfaceTransaction *tx = api.txCreate();
if (!tx) return -1;
ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height};
api.setBuffer(tx, g_base_sc, black, -1);
api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE);
api.setZOrder(tx, g_base_sc, 0); /* GAME = 1, OVERLAY = 2 */
api.setGeometry(tx, g_base_sc, &src, &dst, 0);
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW);
api.txApply(tx); api.txDelete(tx);
g_base_w = width; g_base_h = height; g_base_shown = 1;
return 0;
}
static void base_hide(int retire) {
if (!g_base_sc) return;
ASurfaceTransaction *tx = api.txCreate();
if (tx) {
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_HIDE);
if (retire) api.reparent(tx, g_base_sc, NULL);
api.txApply(tx); api.txDelete(tx);
g_base_shown = 0;
}
if (retire) {
api.release(g_base_sc); g_base_sc = NULL;
g_base_w = g_base_h = g_base_shown = 0;
}
}
void sc_layer_hide_base(void) { base_hide(0); }
static void replace_with_blank(ASurfaceTransaction *tx, struct layer *l) {
AHardwareBuffer *b = blank_buffer();
if (b) api.setBuffer(tx, l->sc, b, -1);
@@ -927,6 +973,7 @@ void sc_layer_hide_overlay(void) {
}
void sc_layer_window_gone(void) {
base_hide(1); /* the backdrop belongs to the old window, just like GAME and OVERLAY */
if (api.state != 1) return;
layers_init();
for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]);
@@ -36,6 +36,8 @@ int sc_layer_overlay_affordable(void);
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
void sc_layer_hide(void);
int sc_layer_base_black(int width, int height);
void sc_layer_hide_base(void);
void sc_layer_hide_overlay(void);
void sc_layer_window_gone(void);
+107 -7
View File
@@ -8,9 +8,11 @@
#include "hdr_compose.h"
#include "blend_pass.h"
#include "droiddeck_color.h"
#include "quest_device.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/system_properties.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
@@ -64,6 +66,21 @@ static VkExtent2D g_extent;
static int g_first_frame_done;
static int g_base_black;
int vkp_quest_layer_presentation(void) {
/* Compositor thread only; this is a process-wide device property, not a session setting.
* Quest 2 still needs its separate A6xx driver: this gate only chooses presentation. */
static int enabled = -1;
if (enabled < 0) {
char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0};
__system_property_get("ro.product.model", model);
__system_property_get("ro.product.device", device);
__system_property_get("ro.product.manufacturer", maker);
enabled = quest_device_matches(model, device, maker);
if (enabled) droiddeck_log("gpu", "Quest presentation: display layers for Steam and desktop, CPU-initialized black backdrop; independent of HDR");
}
return enabled;
}
static VkExtent2D g_caps_extent;
static int64_t g_surface_checked_ns;
@@ -121,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;
@@ -130,6 +150,9 @@ static struct {
int rx, ry, rw, rh;
int valid;
} g_map;
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
static float g_game_picture_rect[4];
static int g_game_picture_top;
static int64_t g_swap_retry_at_ns;
static int g_swap_fail_logged;
@@ -198,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;
}
@@ -228,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;
}
@@ -244,7 +295,7 @@ int vkp_device_lost(void) { return g_dev_state == -2; }
void vk_present_set_scale_mode(int mode, int alignment) {
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_BOTTOM) alignment = VKP_ALIGN_CENTER;
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
g_mode = mode;
g_align = alignment;
}
@@ -263,6 +314,8 @@ static const char *align_name(int a) {
switch (a) {
case VKP_ALIGN_TOP: return "top";
case VKP_ALIGN_BOTTOM: return "bottom";
case VKP_ALIGN_PICTURE_TOP: return "picture top";
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
default: return "center";
}
}
@@ -298,13 +351,30 @@ static void update_map(int scene_w, int scene_h) {
g_map.off_x = (float)(g_map.rx + (int)((g_map.rw - scene_w * aspect) * 0.5f));
g_map.off_y = (float)(g_map.ry + (int)((g_map.rh - scene_h * aspect) * 0.5f));
}
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
g_map.valid = 1;
pthread_mutex_lock(&g_game_picture_lock);
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
g_game_picture_rect[0] = g_map.off_x;
g_game_picture_rect[1] = g_map.off_y;
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
g_game_picture_rect[3] = g_map.off_y + scene_h * g_map.ky;
pthread_mutex_unlock(&g_game_picture_lock);
droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
mode_name(mode), align_name(align), scene_w, scene_h,
(int)(scene_w * g_map.kx + 0.5f), (int)(scene_h * g_map.ky + 0.5f),
(int)g_map.off_x, (int)g_map.off_y, W, H);
}
int vkp_game_picture_rect(float rect[4]) {
pthread_mutex_lock(&g_game_picture_lock);
int active = g_game_picture_top;
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
pthread_mutex_unlock(&g_game_picture_lock);
return active;
}
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy) {
if (!g_map.valid || g_map.kx <= 0 || g_map.ky <= 0) return 0;
*sx = (ox - g_map.off_x) / g_map.kx;
@@ -1313,6 +1383,10 @@ static void check_surface_changed(void) {
int vkp_base_black(int scene_w, int scene_h) {
vkp_apply_window_request();
if (vkp_quest_layer_presentation()) {
if (vkp_update_map(scene_w, scene_h) != 0) return -1;
return sc_layer_base_black((int)g_extent.width, (int)g_extent.height);
}
check_surface_changed();
if (g_base_black && g_swapchain && g_window && g_dev_state == 1) {
g_perf.base_kept++;
@@ -1391,6 +1465,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
const struct vkp_hdr_frame *hf, int *how) {
if (how) *how = 0;
vkp_apply_window_request();
sc_layer_hide_base();
check_surface_changed();
if (g_dev_state == -2) return -1;
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
@@ -1648,6 +1723,26 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
}
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) {
/* The desktop (multiple windows), overlays and an empty scene must also avoid WSI.
* Merely enabling zero-copy handles only a single fullscreen window and leaves both
* the letterbox and every fallback frame going through the corrupted swapchain. */
vkp_apply_window_request();
sc_layer_hide_overlay();
if (n > 0) {
if (sc_layer_present_pass(draws, n, scene_w, scene_h) != 0) {
static int said;
if (!said) { said = 1; droiddeck_log("error", "Quest scene layer unavailable; retaining the previous frame rather than using the corrupt Android swapchain"); }
return -1;
}
} else {
sc_layer_hide();
}
/* sc_layer_present_pass signals only after submitting client content.
* A backdrop alone, or a successful no-op while a layer slot is unavailable,
* must not dismiss the app's loading UI or mark Steam's window as shown. */
return vkp_base_black(scene_w, scene_h);
}
return render_impl(scene_w, scene_h, draws, n, NULL, NULL);
}
@@ -1681,6 +1776,15 @@ void vkp_signal_first_frame(void) {
int vkp_update_map(int scene_w, int scene_h) {
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
if (vkp_quest_layer_presentation()) {
/* Layer geometry needs the window size, not a Vulkan swapchain. In particular, do
* not create/present a swapchain just to paint black under an otherwise clean layer. */
int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window);
if (w <= 0 || h <= 0) return -1;
g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h};
update_map(scene_w, scene_h);
return g_map.valid ? 0 : -1;
}
check_surface_changed();
if (!g_swapchain && swap_init() != 0) return -1;
update_map(scene_w, scene_h);
@@ -1889,11 +1993,7 @@ int vkp_pass_begin_hdr(int scene_w, int scene_h, const struct vkp_draw *draws, i
static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draws, int n,
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
if (g_pass.active) vkp_pass_abort();
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
check_surface_changed();
if (!g_swapchain && swap_init() != 0) return -1;
update_map(scene_w, scene_h);
if (!g_map.valid) return -1;
if (vkp_update_map(scene_w, scene_h) != 0) return -1;
if (hf) {
if (ensure_img(&g_hdrscene, scene_w, scene_h, HDR_FMT,
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT |
@@ -8,10 +8,13 @@ void vk_present_set_driver(const char *driver_path, const char *library_name,
void vk_present_set_window(ANativeWindow *window);
int vkp_apply_window_request(void);
int vkp_quest_layer_presentation(void);
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
VKP_MODE_INTEGER = 4 };
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2 };
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
void vk_present_set_scale_mode(int mode, int alignment);
int vkp_game_picture_rect(float rect[4]);
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
void vkp_output_size(int *w, int *h);
@@ -48,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);
@@ -18,6 +18,7 @@
#include "droiddeck_color.h"
extern int droiddeck_wayland_run(void);
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
extern void droiddeck_wayland_send_pointer(int action, int x, int y);
extern void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y);
extern int droiddeck_cursor_snapshot(int *out, int cap);
@@ -354,11 +355,20 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScaleMode(JNIEnv *env, jclass clazz, jint mode, jint alignment) {
vk_present_set_scale_mode(mode, alignment);
droiddeck_wayland_set_scale_mode(mode, alignment);
__android_log_print(ANDROID_LOG_INFO, TAG, "scale mode %d alignment %d", mode, alignment);
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGamePictureRect(JNIEnv *env, jclass clazz, jfloatArray out) {
if (!out || (*env)->GetArrayLength(env, out) < 4) return JNI_FALSE;
float rect[4];
if (!vkp_game_picture_rect(rect)) return JNI_FALSE;
(*env)->SetFloatArrayRegion(env, out, 0, 4, rect);
return JNI_TRUE;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUpscaler(JNIEnv *env, jclass clazz, jint mode) {
vkp_effects_set_scaling(mode);
@@ -582,6 +592,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;
@@ -20,6 +20,7 @@ class App : Application() {
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)
BwrapSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(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
@@ -20,7 +21,13 @@ import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.QuestExperimental
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.DriverSlot
import com.droiddeck.launcher.ui.GPU_MODE_AUTO
import com.droiddeck.launcher.ui.GPU_MODE_MIX
import com.droiddeck.launcher.ui.GPU_MODE_PAIR
import com.droiddeck.launcher.ui.GpuDriversState
import com.droiddeck.launcher.ui.PairRow
import com.droiddeck.launcher.wayland.CompositorHost
@@ -41,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),
@@ -51,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("")
@@ -89,21 +156,30 @@ 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() {
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val recommended = DriverPairs.recommendedKey(gpu, pairs)
val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs)
pairRows = pairs.map { p ->
val bundle = p.bundle?.let { installedBundle(it) }
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
@@ -120,18 +196,56 @@ 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(
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_quest_restart else R.string.drivers_using_quest,
)
refreshDrivers()
return
}
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
return
@@ -139,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()
@@ -156,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)
}
}
@@ -165,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)
}
@@ -174,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
@@ -189,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 },
@@ -295,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,
@@ -411,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
@@ -109,6 +110,7 @@ class MainActivity : ComponentActivity() {
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
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)
@@ -150,15 +152,21 @@ class MainActivity : ComponentActivity() {
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
private var navRequest by mutableStateOf<String?>(null)
private var storesShowMature by mutableStateOf(false)
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
private var storesView by mutableStateOf(SessionPrefs.STORES_VIEWS.first())
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
@@ -201,6 +209,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) }
}
@@ -245,6 +256,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))
@@ -321,6 +364,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)
@@ -511,11 +559,17 @@ class MainActivity : ComponentActivity() {
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
downloadSource = DownloadSource.mode(this)
downloadMirror = DownloadSource.mirror(this)
downloadPrefix = DownloadSource.custom(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
storesShowMature = SessionPrefs.storesShowMature(this)
storesSort = SessionPrefs.storesSort(this)
storesView = SessionPrefs.storesView(this)
applyLauncherFullscreen()
updates.start()
setContent {
@@ -565,6 +619,9 @@ class MainActivity : ComponentActivity() {
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherFullscreen = launcherFullscreen,
downloadSource = downloadSource,
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
animationsEnabled = animationsEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
@@ -573,10 +630,17 @@ class MainActivity : ComponentActivity() {
addedGamesArt = addedGamesArt,
sgdbUserKey = sgdbUserKey,
storesShowMature = storesShowMature,
storesSort = storesSort,
storesView = storesView,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
onPlay = { playSteam() },
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)
@@ -597,6 +661,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 ->
@@ -728,12 +793,26 @@ class MainActivity : ComponentActivity() {
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
onNavigated = { navRequest = null },
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view },
onLibraryChanged = { refreshAddedGames(); refresh() },
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
com.droiddeck.launcher.ui.Motion.refresh(this)
},
onDownloadSource = { mode ->
DownloadSource.setMode(this, mode)
downloadSource = mode
},
onDownloadMirror = { host ->
DownloadSource.setMirror(this, host)
downloadMirror = host
},
onDownloadPrefix = { prefix ->
DownloadSource.setCustom(this, prefix)
downloadPrefix = DownloadSource.custom(this)
},
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -841,6 +920,14 @@ 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 },
)
}
if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog(
onConfirm = { gameProfiles -> resetSettings(gameProfiles) },
onDismiss = { showResetSettings = false },
@@ -893,6 +980,14 @@ class MainActivity : ComponentActivity() {
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
private fun playSteam() {
val storage = com.droiddeck.launcher.storage.StorageState
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
lowSpace = storage.freeBytes(this)
storage.refresh(this)
} else startSteamSession()
}
private fun startSteamSession() {
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
}
@@ -956,7 +1051,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())
@@ -1018,7 +1113,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?) {
@@ -1152,6 +1249,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,
@@ -1246,6 +1345,7 @@ class MainActivity : ComponentActivity() {
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
@@ -1258,6 +1358,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,
@@ -1306,6 +1411,7 @@ class MainActivity : ComponentActivity() {
backActionsInverted = inverted
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onPicturePosition = { value -> SessionPrefs.setPicturePosition(this, value); picturePosition = value },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1337,6 +1443,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 },
@@ -1407,6 +1519,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()
@@ -1454,6 +1580,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)
@@ -1465,6 +1592,7 @@ class MainActivity : ComponentActivity() {
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
picturePosition = SessionPrefs.picturePosition(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
@@ -1525,6 +1653,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)
@@ -1544,6 +1673,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 }
@@ -1572,7 +1702,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)
}
@@ -1601,6 +1735,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
@@ -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)
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
}
}
@@ -120,6 +120,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private fun applyGameAlignment() {
if (!CompositorHost.isStarted) return
val active = SessionState.mode == SessionService.MODE_STEAM &&
!pipUi && !isInPictureInPictureMode && !cardboard.enabled
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
"top" -> ALIGN_PICTURE_TOP
"bottom" -> ALIGN_PICTURE_BOTTOM
else -> ALIGN_CENTER
}
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
}
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
private lateinit var sessionRoot: FrameLayout
private val guestKeysDown = mutableSetOf<Int>()
@@ -162,6 +175,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
private var cursorVisible by mutableStateOf(false)
@@ -177,6 +191,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (pointerLocked == locked) return@post
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
if (locked && ::touchpad.isInitialized) {
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
cursorVisible = false
}
updatePointerCapture()
}
}
@@ -220,6 +239,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var leaving by mutableStateOf(false)
private var closeAtOnce = false
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerDefaultProton by mutableStateOf<String?>(null)
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
private var drawerGameProfileId by mutableStateOf<Long?>(null)
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
@@ -243,6 +263,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
private var lowLatencyController by mutableStateOf(false)
private var lowLatencyTouch by mutableStateOf(false)
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var pcKeyboardOpen by mutableStateOf(false)
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
@@ -308,16 +330,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.syncConnectedControllers()
updateOnScreenControls()
refreshControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
padBridge?.onDeviceRemoved(deviceId)
updateOnScreenControls()
refreshControls()
updatePointerCapture()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
override fun onInputDeviceChanged(deviceId: Int) = refreshControls()
}
private var runtimeRemovalBlocked = false
@@ -381,7 +403,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
padBridge = bridge
SessionState.padBridge = bridge
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } }
steamController = SteamControllerBle(this).also { it.start() }
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
@@ -397,6 +419,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
val noCursor = android.view.PointerIcon.getSystemIcon(this, android.view.PointerIcon.TYPE_NULL)
root.pointerIcon = noCursor
surfaceView.pointerIcon = noCursor
@@ -458,6 +481,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
defaultFocusHighlightEnabled = false
setContent {
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -498,8 +522,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releaseDrawerDirection()
}
}
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture) {
while (cardboard.enabled || steamVrSwitchedPicture) {
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture, drawerOpen, drawerPage) {
while (cardboard.enabled || steamVrSwitchedPicture || (drawerOpen && drawerPage == SessionDrawerPage.VR)) {
val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
SteamVr.status(this@SessionActivity)
}
@@ -536,6 +560,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) },
onCardboardRecenter = { headTracker?.recenter() },
steamVr = steamVrStatus,
immersiveSteamVr = com.droiddeck.launcher.session.QuestExperimental.isSupported(),
onSteamVr = { action -> steamVrAction(action) },
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
@@ -612,6 +637,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
lowLatencyController = lowLatencyController,
lowLatencyTouch = lowLatencyTouch,
onLowLatencyController = { on ->
SessionPrefs.setLowLatencyController(this@SessionActivity, on)
lowLatencyController = on
applyLowLatency()
},
onLowLatencyTouch = { on ->
SessionPrefs.setLowLatencyTouch(this@SessionActivity, on)
lowLatencyTouch = on
applyLowLatency()
},
onOsc = { v ->
SessionPrefs.setOscMode(this@SessionActivity, v)
readPrefs()
@@ -704,6 +741,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onGameNoSteam = ::setDrawerGameNoSteam,
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
onGameComponent = ::setDrawerGameComponent,
defaultProtonId = drawerDefaultProton,
onDefaultProton = ::chooseDrawerDefaultProton,
onGameProton = ::setDrawerGameProton,
))
LaunchedEffect(achievementsOpen) {
if (achievementsOpen) {
@@ -823,8 +863,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(this, s.protons.map { it.proton }) }.getOrNull() }
uiHandler.post {
drawerComponents = snap
drawerDefaultProton = chosen
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
@@ -968,6 +1010,46 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "drawer-game-component").start()
}
private fun chooseDrawerDefaultProton(protonId: String) {
val proton = drawerComponents?.protons?.firstOrNull { it.proton.id == protonId }?.proton ?: return
drawerDefaultProton = protonId
Thread({
val message = runCatching {
when (ProtonDefault.request(this, proton)) {
ProtonDefault.Outcome.LIVE -> getString(R.string.comp_default_live, proton.name)
ProtonDefault.Outcome.NEXT_START -> getString(R.string.comp_default_next, proton.name)
ProtonDefault.Outcome.NOT_RUNNABLE -> getString(R.string.comp_default_not_runnable, proton.name)
}
}.getOrElse { e -> getString(R.string.comp_default_failed, e.message ?: e.javaClass.simpleName) }
uiHandler.post {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
refreshDrawerComponents()
refreshDrawerGameProfile()
}
}, "drawer-default-proton").start()
}
private fun setDrawerGameProton(appId: Long, protonId: String?) {
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
val proton = protonId?.let { id -> drawerComponents?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return }
Thread({
val message = runCatching {
getString(
when (ProtonDefault.requestGame(this, appId.toString(), proton)) {
ProtonDefault.Outcome.LIVE -> R.string.comp_game_proton_live
ProtonDefault.Outcome.NEXT_START -> R.string.comp_game_proton_next
ProtonDefault.Outcome.NOT_RUNNABLE -> R.string.comp_game_proton_not_runnable
},
name,
)
}.getOrElse { e -> getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName) }
uiHandler.post {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
refreshDrawerGameProfile()
}
}, "drawer-game-proton").start()
}
private fun setDrawerGameFexPreset(appId: Long, preset: String?) {
if (drawerGameFexPresetSaving) return
drawerGameFexPresetSaving = true
@@ -1051,6 +1133,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
picturePosition = SessionPrefs.picturePosition(this)
hudOn = SessionPrefs.hudEnabled(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
@@ -1081,6 +1164,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
lowLatencyController = SessionPrefs.lowLatencyController(this)
lowLatencyTouch = SessionPrefs.lowLatencyTouch(this)
padType = SessionPrefs.steamController(this)
}
@@ -1212,6 +1297,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val turnip = TurnipDriver(this)
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "surface.driver-setup")
if (!CompositorHost.isStarted) CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
val driverId = if (CompositorHost.isStarted) null else turnip.install()
val previous = SessionState.outputSize
@@ -1286,6 +1372,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
}
applyFrameGen()
applyGameAlignment()
}
private var surfaceW = 0
@@ -1705,9 +1792,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) = movePointer(x, y)
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
}
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
movePointer(x, y)
if (!pointerLocked) movePointer(x, y)
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
}
override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
@@ -1717,6 +1806,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun moveLockedPointer(x: Float, y: Float) {
val rect = drawnRect() ?: return
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta((x - rect.centerX()) * scale, (y - rect.centerY()) * scale)
touchpad.place(rect.centerX(), rect.centerY())
cursorVisible = false
}
private fun movePointer(x: Float, y: Float) {
val rect = drawnRect() ?: return
val cx = x.coerceIn(rect.left, rect.right)
@@ -1825,6 +1922,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onSteamMenu = ::tapSteamButton,
onQam = ::tapQam,
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
pad = padBridge,
)
secondScreenPresentation = presentation
}
@@ -1859,11 +1957,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyUnfoldedControls()
}
private fun refreshControls() {
applyUnfoldedControls()
updateOnScreenControls()
}
private fun applyUnfoldedControls() {
if (!::sessionRoot.isInitialized) return
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
!PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
@@ -1939,9 +2043,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
}
private val gamePictureBounds = FloatArray(4)
private var topPictureMapped = false
private fun drawnRect(): android.graphics.RectF? {
val width = surfaceView.width.takeIf { it > 0 } ?: return null
val height = surfaceView.height.takeIf { it > 0 } ?: return null
topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds)
if (topPictureMapped) return android.graphics.RectF(
gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
@@ -1951,9 +2061,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return android.graphics.RectF(left, top, left + drawnW, top + drawnH)
}
private var ignoreGameTouch = false
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") {
val wasActive = threeFingerTap.active
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
if (threeFingerTap.active && !wasActive) {
if (usingTouchpad()) touchpad.cancel() else WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
if (threeFingerTap.active || wasActive) return true
}
if (usingTouchpad()) {
val rect = drawnRect() ?: return false
if (touchpad.bounds != rect) {
@@ -1964,6 +2084,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return touchpad.onTouch(event)
}
val rect = drawnRect() ?: return false
if (event.actionMasked == MotionEvent.ACTION_DOWN)
ignoreGameTouch = topPictureMapped && !rect.contains(event.x, event.y)
if (ignoreGameTouch) {
if (event.actionMasked == MotionEvent.ACTION_DOWN)
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
return true
}
fun sendTouch(action: Int, index: Int) {
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
@@ -2274,12 +2402,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
onScreenControls?.reload()
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
resumed = true
updatePointerCapture()
if (!pipUi) sessionClipboard.start()
updatePadMotion()
readPrefs()
refreshControls()
if (::pip.isInitialized) pip.resumed()
if (CompositorHost.isStarted) applyFrameGen()
SessionPrefs.sessionPrefs(this).registerOnSharedPreferenceChangeListener(cardboardPrefListener)
@@ -2304,6 +2432,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun steamVrAction(action: String) {
if (action == "start" && com.droiddeck.launcher.session.QuestExperimental.isSupported()) {
startActivity(android.content.Intent(this, QuestXrActivity::class.java))
return
}
if (action == "start") {
switchPictureForSteamVr()
steamVrSeenRunning = false
@@ -2389,8 +2521,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onAttachedToWindow()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
val decor = window.decorView
decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES)
decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES) } }
decor.requestUnbufferedDispatch(unbufferedSources())
decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(unbufferedSources()) } }
}
private fun unbufferedSources() =
if (lowLatencyTouch) UNBUFFERED_SOURCES or InputDevice.SOURCE_CLASS_POINTER else UNBUFFERED_SOURCES
private fun applyLowLatency() {
com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(lowLatencyController, lowLatencyTouch)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) window.decorView.requestUnbufferedDispatch(unbufferedSources())
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
@@ -2512,6 +2652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val DRAWER_REPEAT_ACCELERATION_MS = 1_400L
private const val SCALE_FIT = 1
private const val ALIGN_CENTER = 0
private const val ALIGN_PICTURE_TOP = 3
private const val ALIGN_PICTURE_BOTTOM = 4
private val RUNTIME_LINES = PackageLines(
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
@@ -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,63 +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 (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) {
connection.disconnect();
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
}
if (code / 100 != 2) {
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)) {
@@ -77,20 +215,66 @@ 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();
}
}
private static HttpURLConnection open(String url) throws java.io.IOException {
/** True when a `Content-Range: bytes START-END/TOTAL` header starts exactly at {@code start}. */
static boolean rangeStartsAt(String contentRange, long start) {
if (contentRange == null) return false;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+(\\d+)-\\d+/(?:\\d+|\\*)\\s*$").matcher(contentRange);
return m.matches() && Long.parseLong(m.group(1)) == start;
}
/** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
static long rangeTotal(String contentRange) {
if (contentRange == null) return -1;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange);
return m.matches() ? Long.parseLong(m.group(1)) : -1;
}
private static final class Fetch {
final String body;
final boolean fatal;
Fetch(String body, boolean fatal) {
this.body = body;
this.fatal = fatal;
}
}
private static Fetch fetch(String url) {
HttpURLConnection connection = null;
try {
connection = open(url, STALL_TIMEOUT_MS);
int code = connection.getResponseCode();
if (code / 100 != 2) {
Log.w(TAG, url + " -> HTTP " + code);
return new Fetch(null, code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE);
}
try (InputStream in = connection.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
FileUtils.copy(in, out);
return new Fetch(new String(out.toByteArray(), StandardCharsets.UTF_8), false);
}
} catch (Exception e) {
Log.w(TAG, "GET " + url, e);
return new Fetch(null, false);
} finally {
if (connection != null) connection.disconnect();
}
}
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
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;
@@ -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"
@@ -36,10 +36,14 @@ object AddedGameArt {
class Option(val group: String, val thumb: String, val full: String)
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
class Choices(val options: List<Option>, val sgdb: SteamGridDb.Status)
private fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
class Found<T>(val items: List<T>, val status: SteamGridDb.Status)
private val CHOSEN = Regex("""chosen-(p|header|hero|logo|icon)(?:-\d+)?\.(\w+)""")
internal fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.maxWithOrNull(compareBy({ it.lastModified() }, { it.name }))
private val IMAGE = listOf("png", "jpg", "jpeg")
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
@@ -75,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),
@@ -92,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 }
@@ -108,42 +113,88 @@ object AddedGameArt {
dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty()
}.take(12)
fun options(context: Context, game: AddedGames.Game, slot: Slot): List<Option> {
fun choices(context: Context, game: AddedGames.Game, slot: Slot): Choices {
val out = ArrayList<Option>()
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
val cache = cacheDir(context, game)
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
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))
}
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.isNotEmpty()) {
cache.mkdirs()
val gridId = remembered(File(cache, "sgdb-id")) {
runCatching { SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name) }.getOrNull()?.toString()
}?.toIntOrNull()
if (gridId != null) runCatching { SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5) }.getOrNull()
?.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
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): Found<Option> = sgdb(context) { key ->
SteamGridDb.all(key, id, slot).map { (thumb, full) -> Option(SGDB, thumb, full) }
}
private fun <T> sgdb(context: Context, call: (String) -> List<T>): Found<T> {
val key = SteamGridDb.key(context)
if (key.isEmpty()) return Found(emptyList(), SteamGridDb.Status.NO_KEY)
return try {
Found(call(key), SteamGridDb.Status.OK)
} catch (e: Exception) {
Log.w(TAG, "SteamGridDB: ${e.javaClass.simpleName}")
Found(emptyList(), (e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER)
}
return out
}
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('|', "")
@@ -180,6 +231,8 @@ object AddedGameArt {
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
if (!SessionPrefs.addedGamesArt(context)) return false
val sgdbKey = SteamGridDb.key(context)
val keyChangedAt = SteamGridDb.keyChangedAt(context)
var sgdbDown = false
var changed = false
for (game in games) {
if (local(game).portrait != null) continue
@@ -192,11 +245,24 @@ object AddedGameArt {
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
val id = match.appId?.toString()
if (id != null && download(id, cache)) changed = true
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
val gridId = remembered(File(cache, "sgdb-id")) {
runCatching { SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name) }.getOrNull()?.toString()
}?.toIntOrNull() ?: continue
if (runCatching { SteamGridDb.download(sgdbKey, gridId, cache) }.getOrDefault(false)) changed = true
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty() || sgdbDown) continue
val stored: String? = try {
remembered(File(cache, "sgdb-id"), keyChangedAt) {
SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString()
}
} catch (e: Exception) {
Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass")
sgdbDown = true
null
}
val gridId = stored?.toIntOrNull() ?: continue
val grabbed = try {
SteamGridDb.download(sgdbKey, gridId, cache)
} catch (e: Exception) {
sgdbDown = true
false
}
if (grabbed) changed = true
}
return changed
}
@@ -207,11 +273,12 @@ object AddedGameArt {
return match
}
private fun remembered(marker: File, find: () -> String?): String? {
internal fun remembered(marker: File, notBefore: Long = 0L, retryMiss: Boolean = false, find: () -> String?): String? {
if (marker.isFile) {
val value = marker.readText().trim()
if (value != "none") return value
if (System.currentTimeMillis() - marker.lastModified() < RETRY_AFTER_MS) return null
val at = marker.lastModified()
if (!retryMiss && at >= notBefore && System.currentTimeMillis() - at < RETRY_AFTER_MS) return null
}
val found = find()
marker.writeText(found ?: "none")
@@ -36,19 +36,24 @@ 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)
}
}
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)
@@ -330,7 +330,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]) }
@@ -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 }
}
@@ -76,7 +76,7 @@ object DependencyDetector {
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
val name = f.name.lowercase(Locale.US)
val path = f.path.replace('\\', '/').lowercase(Locale.US)
val path = relativeReason(gameDir, f).lowercase(Locale.US)
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
Kind.BUNDLED else Kind.SHIPPED
for (comp in classify(path, name, isDir)) {
@@ -5,12 +5,14 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import java.io.File
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
@@ -19,22 +21,35 @@ object GameShortcuts {
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
internal fun iconBitmap(game: Library.SteamGame): Bitmap? {
val icon = game.icon?.let(::decode)
if (icon != null && minOf(icon.width, icon.height) >= SHARP_ICON_PX) return icon
return game.art?.let(::decode)?.let(::square) ?: icon
}
private fun decode(file: File): Bitmap? = runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (minOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= ICON_PX) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)
}.getOrNull()
private fun square(bitmap: Bitmap): Bitmap {
val side = minOf(bitmap.width, bitmap.height)
return Bitmap.createBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side)
}
fun pin(activity: Activity, game: Library.SteamGame) {
val manager = activity.getSystemService(ShortcutManager::class.java)
val requested = runCatching { manager.isRequestPinShortcutSupported &&
@@ -44,4 +59,7 @@ object GameShortcuts {
fun result(activity: Activity, game: Library.SteamGame): Intent =
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
private const val ICON_PX = 256
private const val SHARP_ICON_PX = 128
}
@@ -17,6 +17,9 @@ 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()
}
@@ -69,7 +72,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,21 +119,33 @@ 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()
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
return cache.listFiles().orEmpty().mapNotNull { dir ->
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
}
}
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
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 =
@@ -144,7 +159,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 }
@@ -0,0 +1,91 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
object SteamFeatured {
class Game(val appId: Int, val name: String, val art: String)
private const val TAG = "SteamFeatured"
private const val PREFS = "steam_featured"
private const val KEY_GAMES = "games_v2"
private const val KEY_AT = "at"
private const val FRESH_MS = 6 * 60 * 60 * 1000L
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
var games by mutableStateOf<List<Game>>(emptyList())
private set
private val loading = AtomicBoolean(false)
fun load(context: Context) {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
if (!loading.compareAndSet(false, true)) return
Thread({
try {
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
val root = JSONObject(body)
val ids = LinkedHashSet<Int>()
for (section in SECTIONS) {
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
for (i in 0 until items.length()) {
val o = items.optJSONObject(i) ?: continue
val id = o.optInt("id")
if (id > 0 && o.optInt("type", 0) == 0) ids += id
}
}
val list = withArt(ids, cc)
if (list.isEmpty()) return@Thread
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
.putLong(KEY_AT, System.currentTimeMillis()).apply()
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
Log.i(TAG, "featured games: ${list.size}")
} catch (e: Exception) {
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
} finally {
loading.set(false)
}
}, "steam-featured").start()
}
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
if (ids.isEmpty()) return emptyList()
val input = JSONObject()
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
.put("context", JSONObject().put("language", "english").put("country_code", cc))
.put("data_request", JSONObject().put("include_assets", true))
val body = StoreNet.get(
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
) ?: return emptyList()
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
return (0 until items.length()).mapNotNull { i ->
val o = items.optJSONObject(i) ?: return@mapNotNull null
val descriptors = o.optJSONArray("content_descriptorids")
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
?: return@mapNotNull null
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
}
}
private fun parse(text: String?): List<Game>? {
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
}
}
@@ -0,0 +1,91 @@
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, "")
}
}
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,119 @@
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
}
/** 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}")
}
}
if (changed) Log.i(TAG, "took over art changed in Steam")
return changed
}
}
@@ -16,6 +16,12 @@ object SteamGridDb {
private const val API = "https://www.steamgriddb.com/api/v2"
private const val QUERY = "nsfw=false&humor=false"
enum class Status { OK, NO_KEY, BAD_KEY, LIMIT, OFFLINE, SERVER, NO_GAME }
class Failed(val status: Status) : Exception(status.name)
class Game(val id: Int, val name: String, val verified: Boolean)
@VisibleForTesting
@Volatile
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
@@ -30,6 +36,8 @@ object SteamGridDb {
fun key(context: Context): String = pick(userKey(context), builtInKey())
fun keyChangedAt(context: Context): Long = keyFile(context).lastModified()
@Synchronized
fun userKey(context: Context): String {
val f = keyFile(context)
@@ -97,17 +105,56 @@ object SteamGridDb {
return got
}
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> {
val path = when (slot) {
fun search(key: String, term: String): List<Game> {
val q = term.trim()
if (q.isEmpty()) return emptyList()
val enc = URLEncoder.encode(q, "UTF-8").replace("+", "%20")
val data = json(key, "$API/search/autocomplete/$enc")?.optJSONArray("data") ?: return emptyList()
return games(data)
}
internal fun games(data: JSONArray): List<Game> =
(0 until data.length()).mapNotNull { data.optJSONObject(it) }
.filter { it.optInt("id") > 0 && it.optString("name").isNotEmpty() }
.map { Game(it.optInt("id"), it.optString("name"), it.optBoolean("verified")) }
internal fun path(id: Int, slot: AddedGameArt.Slot): String =
when (slot) {
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"
}
val items = json(key, "$API/$path")?.optJSONArray("data") ?: return emptyList()
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON).take(limit)
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).take(limit)
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
(0 until items.length()).map { items.getJSONObject(it) }
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
@@ -119,6 +166,13 @@ object SteamGridDb {
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
.maxByOrNull { it.optInt("score", 0) }?.optString("url")
internal fun failure(code: Int): Status? = when {
code == 401 || code == 403 -> Status.BAD_KEY
code == 429 -> Status.LIMIT
code >= 500 -> Status.SERVER
else -> null
}
private fun json(key: String, url: String): JSONObject? {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 10_000
@@ -126,8 +180,19 @@ object SteamGridDb {
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
c.setRequestProperty("Authorization", "Bearer $key")
return try {
if (c.responseCode != 200) { Log.w(TAG, "${URL(url).path}: HTTP ${c.responseCode}"); null }
else JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
val code = c.responseCode
if (code == 200) {
JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
} else {
Log.w(TAG, "${URL(url).path}: HTTP $code")
failure(code)?.let { throw Failed(it) }
null
}
} catch (e: Failed) {
throw e
} catch (e: java.io.IOException) {
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}")
throw Failed(Status.OFFLINE)
} catch (e: Exception) {
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}"); null
} finally {
@@ -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)
}
@@ -81,7 +81,7 @@ data class GpuInfo(
)
}
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
internal fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
private val PLATFORMS = mapOf(
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
@@ -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}") }
}
}
@@ -8,6 +8,7 @@ import android.util.Log;
import com.droiddeck.launcher.R;
import com.droiddeck.launcher.core.FileUtils;
import com.droiddeck.launcher.core.TarZst;
import com.droiddeck.launcher.session.QuestExperimental;
import com.droiddeck.launcher.session.SessionPrefs;
import org.json.JSONObject;
@@ -281,6 +282,8 @@ public final class TurnipDriver {
if (forced.startsWith("a7")) return DRIVER_A7XX;
if (forced.startsWith("a6")) return DRIVER_A6XX;
}
String quest = QuestExperimental.INSTANCE.defaultAndroidDriver();
if (quest != null) return quest;
String model = gpuModel();
Log.i(TAG, "gpu model: " + (model == null ? "unknown" : model));
if (model != null) {
@@ -1,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 =
@@ -79,6 +79,10 @@ public class FakeInputWriter {
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40;
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
private static final int PAD_TYPE_SET = 0x80000000;
private static final int LATENCY_OFFSET = IMU_OFFSET + 48;
public static final int LATENCY_CONTROLLER = 1;
public static final int LATENCY_TOUCH = 2;
private static volatile int latency = 0;
public static final int PAD_TYPE_DECK = 0;
public static final int PAD_TYPE_STEAM = 1;
public static final int PAD_TYPE_XBOX = 2;
@@ -214,6 +218,7 @@ public class FakeInputWriter {
data.putInt(DECK_CONTROLS_OFFSET, 0);
int type = padType;
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
data.putInt(LATENCY_OFFSET, latency);
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
@@ -514,6 +519,22 @@ public class FakeInputWriter {
}
}
public static void setLowLatency(boolean controller, boolean touch) {
int flags = (controller ? LATENCY_CONTROLLER : 0) | (touch ? LATENCY_TOUCH : 0);
latency = flags;
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = RING_SLOTS[0];
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
nativeStoreFence();
ring.putInt(LATENCY_OFFSET, flags);
}
}
public static void setTritonConnected(int slot, boolean connected) {
tritonConnected = connected;
RingSlot ringSlot = tritonSlot(slot);
@@ -19,6 +19,7 @@ public final class PadBridge {
private static final float DEAD_ZONE = 0.12f;
private static final int[] JOYSTICK_AXES = {
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
MotionEvent.AXIS_RX, MotionEvent.AXIS_RY,
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
};
@@ -54,7 +55,8 @@ public final class PadBridge {
private boolean systemGuidePressed;
private boolean systemQamPressed;
private boolean qamChordActive;
private boolean qamSyntheticAPressed;
private int chordButton = -1;
private boolean chordButtonPressed;
private boolean qamTapPressed;
private int qamChordGeneration;
private volatile Runnable onPlayerInput;
@@ -100,6 +102,7 @@ public final class PadBridge {
}
public synchronized void stop() {
releaseBackPaddles();
activeControllerId = NO_CONTROLLER;
for (int slot = 0; slot < SLOTS; slot++) {
owners[slot] = NO_CONTROLLER;
@@ -113,7 +116,7 @@ public final class PadBridge {
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
qamSyntheticAPressed = false;
chordButtonPressed = false;
qamTapPressed = false;
qamChordGeneration++;
if (open) {
@@ -243,6 +246,12 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
case KeyEvent.KEYCODE_BUTTON_MODE:
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
case KeyEvent.KEYCODE_BUTTON_Z:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.L4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_C:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.R4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
@@ -269,12 +278,27 @@ public final class PadBridge {
scheduleStats();
pad.leftX = axis(event, MotionEvent.AXIS_X);
pad.leftY = axis(event, MotionEvent.AXIS_Y);
pad.rightX = axis(event, MotionEvent.AXIS_Z);
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
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);
}
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
@@ -317,11 +341,12 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
if (qamStarted && !nativeQam()) startQamChord();
if (qamStarted && !nativeQam()) startChord(PadState.A);
else publish();
}
public synchronized void releaseAll() {
releaseBackPaddles();
state.clear();
publish();
for (int slot = 1; slot < SLOTS; slot++) {
@@ -343,7 +368,7 @@ public final class PadBridge {
public synchronized void triggerQam() {
if (!nativeQam()) {
startQamChord();
startChord(PadState.A);
return;
}
if (qamTapPressed) return;
@@ -359,12 +384,17 @@ public final class PadBridge {
publish();
}
private void startQamChord() {
public synchronized void triggerSteamKeyboard() {
startChord(PadState.X);
}
private void startChord(int button) {
if (qamChordActive) return;
qamChordActive = true;
chordButton = button;
int generation = ++qamChordGeneration;
publish();
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
chordHandler.postDelayed(() -> pressChordButton(generation), qamGuideLeadMs());
}
static long qamGuideLeadMs() {
@@ -373,16 +403,16 @@ public final class PadBridge {
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
}
private synchronized void pressQamA(int generation) {
private synchronized void pressChordButton(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = true;
chordButtonPressed = true;
publish();
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
chordHandler.postDelayed(() -> releaseChordButton(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamA(int generation) {
private synchronized void releaseChordButton(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = false;
chordButtonPressed = false;
publish();
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
}
@@ -415,11 +445,21 @@ public final class PadBridge {
return slot == 0 ? state : playerStates[slot];
}
private boolean isPrimary(KeyEvent event) {
return owners[0] == event.getDevice().getId();
}
private void releaseBackPaddles() {
DeckControls.INSTANCE.setGrip(DeckControls.L4, false);
DeckControls.INSTANCE.setGrip(DeckControls.R4, false);
}
private void free(int slot) {
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
owners[slot] = NO_CONTROLLER;
slotControllers.set(slot, NO_CONTROLLER);
if (slot == 0) {
releaseBackPaddles();
state.clear();
publish();
return;
@@ -491,7 +531,7 @@ public final class PadBridge {
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
if (chordButtonPressed) effectiveState.press(chordButton, true);
writer.writePad(effectiveState);
}
@@ -158,7 +158,8 @@ object SteamTouchConfig {
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
val file = dir?.let { autosaveConfig(it, appId) }
val file = dir?.let { deviceConfig(context, it, appId) }
?: dir?.let { autosaveConfig(it, appId) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
if (file == null) {
@@ -207,11 +208,16 @@ object SteamTouchConfig {
null
}
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
}?.takeIf { it.isFile }
@@ -219,7 +225,16 @@ object SteamTouchConfig {
null
}
internal fun workshopConfig(context: Context, id: String): File? {
if (!id.all(Char::isDigit)) return null
return File(steamRoot(context), "steamapps/workshop/content/$CONFIGS_APP/$id").listFiles()
?.filter { it.isFile && (it.name.endsWith(".vdf") || it.name.endsWith(".bin")) }
?.maxByOrNull { it.lastModified() }
}
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
private const val DEVICE_CONFIGSET = "configset_MT-DROIDDECK0001.vdf"
private const val CONFIGS_APP = 241100
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
@@ -363,10 +378,20 @@ object SteamTouchConfig {
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
dropDeviceEntry(dir, appId)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
private fun dropDeviceEntry(dir: File, appId: Int) {
val file = File(dir, DEVICE_CONFIGSET)
if (!file.isFile) return
val text = file.readText()
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
if (!block.containsMatchIn(text)) return
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
}
private fun selectAutosave(dir: File, appId: Int) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.input
import android.view.MotionEvent
import kotlin.math.hypot
class ThreeFingerTap(private val slopPx: Float) {
var active = false
private set
private val starts = HashMap<Int, Pair<Float, Float>>()
private var downAt = 0L
private var threeAt = 0L
private var valid = false
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
starts.clear()
active = false
valid = true
downAt = event.eventTime
note(event, event.actionIndex)
}
MotionEvent.ACTION_POINTER_DOWN -> {
note(event, event.actionIndex)
if (event.pointerCount > 3) valid = false
if (event.pointerCount == 3 && !active) {
active = true
threeAt = event.eventTime
if (threeAt - downAt > LAND_MS) valid = false
}
}
MotionEvent.ACTION_MOVE -> for (i in 0 until event.pointerCount) {
val start = starts[event.getPointerId(i)] ?: continue
if (hypot(event.getX(i) - start.first, event.getY(i) - start.second) > slopPx) valid = false
}
MotionEvent.ACTION_UP -> {
val fired = active && valid && event.eventTime - threeAt < TAP_MS
active = false
return fired
}
MotionEvent.ACTION_CANCEL -> active = false
}
return false
}
private fun note(event: MotionEvent, index: Int) {
starts[event.getPointerId(index)] = event.getX(index) to event.getY(index)
}
private companion object {
const val LAND_MS = 250L
const val TAP_MS = 350L
}
}
@@ -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()
@@ -230,16 +230,38 @@ public final class LinuxRuntimeInstaller {
}
}
/**
* The download is named after the release, so a partial file is only ever resumed for the same
* version and checksum, never for a different runtime that happens to share a fixed name.
*/
static String archiveName(Release release) {
String version = release.version.replaceAll("[^A-Za-z0-9._-]", "_");
String sum = release.sha256.replaceAll("[^A-Za-z0-9]", "");
if (sum.length() > 12) sum = sum.substring(0, 12);
return "linuxfs-" + version + "-" + sum + ".tar.zst";
}
/** Removes archives left by other releases and by builds that used the fixed name. */
private static void deleteOtherArchives(File dir, String keep) {
File[] files = dir.listFiles((d, name) -> name.startsWith("linuxfs") && name.endsWith(".tar.zst") && !name.equals(keep));
if (files == null) return;
for (File file : files) file.delete();
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
File archive = new File(context.getCacheDir(), archiveName(release));
// A failed download keeps what it fetched so the next attempt resumes it; anything that
// reached verification and failed is not trusted and is removed.
boolean keepPartial = false;
try {
deleteOtherArchives(context.getCacheDir(), archive.getName());
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
if (!discard(pendingRemoval, null)) throw new IOException("Cannot clear the previous runtime: " + pendingRemoval);
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));
@@ -247,6 +269,7 @@ public final class LinuxRuntimeInstaller {
});
if (!ok) {
Log.w(TAG, "download failed");
keepPartial = true;
return false;
}
@@ -300,7 +323,7 @@ public final class LinuxRuntimeInstaller {
Log.e(TAG, "install", e);
return false;
} finally {
archive.delete();
if (!keepPartial) archive.delete();
}
}
@@ -45,8 +45,9 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
val tempDeci = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
val tech = sticky?.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)?.takeIf { it.isNotBlank() } ?: "Li-ion"
val chargeNowUah = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)?.takeIf { it > 0 && it != Long.MIN_VALUE } ?: 0L
val currentNowUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L
val currentAvgUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: currentNowUa
val currentNowUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L)
val currentAvgUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: 0L)
.takeIf { it != 0L } ?: currentNowUa
val discharging = status == BatteryManager.BATTERY_STATUS_DISCHARGING || status == BatteryManager.BATTERY_STATUS_NOT_CHARGING
val chargeFullUah = if (chargeNowUah > 0 && pct > 0) chargeNowUah * 100 / pct else 0L
val energyNowUwh = chargeNowUah * voltageUv / 1_000_000L
@@ -125,6 +126,12 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
companion object {
private const val TAG = "SessionService"
// CURRENT_NOW/CURRENT_AVERAGE are documented as µA, but Samsung and other vendors report mA,
// which made every lifetime estimate 1000x too long. No real µA reading under load is below 10 mA,
// and no mA reading is above 10 A, so anything smaller than 10_000 is taken as mA.
private const val MILLIAMP_CEILING = 10_000L
internal fun toMicroamps(raw: Long): Long = if (abs(raw) < MILLIAMP_CEILING) raw * 1000L else raw
private const val PERIOD_MS = 5_000L
private val VPOWER_FILES = listOf(
"ac_status", "battery_percent", "battery_status",
@@ -0,0 +1,208 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.nio.ByteBuffer
object BootMovie {
private const val TAG = "BootMovie"
const val DROIDDECK_30 = "dd30"
const val DROIDDECK_60 = "dd60"
const val CUSTOM = "custom"
const val MAX_BYTES = 50L shl 20
const val MAX_CONVERT_SECONDS = 60
val PICKER_EXTENSIONS = listOf("webm", "mkv", "mp4", "mov", "m4v", "3gp")
private val ASSETS = mapOf(
DROIDDECK_30 to "droiddeck-startup.webm",
DROIDDECK_60 to "droiddeck-startup-60.webm",
)
// Steam's CEF player only takes WebM codecs; MediaMuxer's WebM writer cannot take AV1.
internal val VIDEO_CODECS = setOf(MediaFormat.MIMETYPE_VIDEO_VP8, MediaFormat.MIMETYPE_VIDEO_VP9, MediaFormat.MIMETYPE_VIDEO_AV1)
private val REMUX_CODECS = setOf(MediaFormat.MIMETYPE_VIDEO_VP8, MediaFormat.MIMETYPE_VIDEO_VP9)
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
sealed class Import {
data class Done(val name: String, val audioDropped: Boolean) : Import()
object TooLong : Import()
data class Unsupported(val codec: String?) : Import()
object Failed : Import()
}
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
private fun customFile(context: Context) = File(dir(context), "custom.webm")
fun customName(context: Context): String? =
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
fun choice(context: Context): String {
val chosen = SessionPrefs.bootMovie(context)
return when {
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
chosen in ASSETS -> chosen
else -> DROIDDECK_30
}
}
fun prepare(context: Context): File? {
val choice = choice(context)
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
if (SessionPrefs.bootMovieSound(context)) return source
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
if (remux(source, muted, audio = false)) return muted
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
return null
}
private fun extractAsset(context: Context, name: String): File? {
val target = File(dir(context), name)
val updated = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime }.getOrDefault(Long.MAX_VALUE)
if (target.isFile && target.lastModified() >= updated) return target
val staged = File(target.parentFile, "$name.staged")
return try {
target.parentFile?.mkdirs()
context.assets.open("steam-startup/$name").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
if (staged.renameTo(target)) target else null
} catch (e: Exception) {
Log.w(TAG, "could not extract $name", e)
null
} finally {
staged.delete()
}
}
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
val video = format.getString(MediaFormat.KEY_MIME)
val audioPlayable = audio == null || audio in AUDIO_CODECS
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
val copy = playable && audioPlayable && isWebm(file)
val remux = !copy && playable && video in REMUX_CODECS
if (!copy && !remux) {
if (!BootMovieTranscoder.canDecode(format)) return Import.Unsupported(video)
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
onStart()
}
val target = customFile(context)
val staged = File(dir(context), "custom.webm.staged")
var audioDropped = remux && !audioPlayable
return try {
target.parentFile?.mkdirs()
val ok = when {
copy -> {
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
true
}
remux -> remux(file, staged, audio = true)
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
}
if (!ok || !staged.renameTo(target)) return Import.Failed
File(target.parentFile, "custom-muted.webm").delete()
SessionPrefs.setBootMovieCustomName(context, file.name)
SessionPrefs.setBootMovie(context, CUSTOM)
Import.Done(file.name, audioDropped)
} catch (e: Exception) {
Log.w(TAG, "could not import ${file.name}", e)
Import.Failed
} finally {
staged.delete()
}
}
fun removeCustom(context: Context) {
customFile(context).delete()
File(dir(context), "custom-muted.webm").delete()
SessionPrefs.setBootMovieCustomName(context, null)
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
}
private fun isWebm(file: File): Boolean {
if (!file.name.endsWith(".webm", ignoreCase = true)) return false
val magic = ByteArray(4)
val read = runCatching { file.inputStream().use { it.read(magic) } }.getOrDefault(0)
return read == 4 && magic.contentEquals(byteArrayOf(0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte()))
}
private fun probe(file: File): Pair<MediaFormat, String?>? {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.path)
var video: MediaFormat? = null
var audio: String? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (video == null && mime.startsWith("video/")) video = format
if (audio == null && mime.startsWith("audio/")) audio = mime
}
video?.let { it to audio }
} catch (e: Exception) {
Log.w(TAG, "could not read ${file.name}", e)
null
} finally {
extractor.release()
}
}
private fun remux(source: File, target: File, audio: Boolean): Boolean {
val staged = File(target.parentFile, target.name + ".remux")
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
return try {
extractor.setDataSource(source.path)
val output = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
val tracks = HashMap<Int, Int>()
var hasVideo = false
var hasAudio = false
var bufferSize = 8 shl 20
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
val keep = mime in VIDEO_CODECS && !hasVideo || audio && mime in AUDIO_CODECS && !hasAudio
if (!keep) continue
if (mime in VIDEO_CODECS) hasVideo = true else hasAudio = true
extractor.selectTrack(i)
tracks[i] = output.addTrack(format)
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
}
if (!hasVideo) return false
output.start()
val buffer = ByteBuffer.allocate(bufferSize)
val info = MediaCodec.BufferInfo()
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0) break
val track = tracks[extractor.sampleTrackIndex]
if (track != null) {
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
info.set(0, size, extractor.sampleTime, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
output.writeSampleData(track, buffer, info)
}
extractor.advance()
}
output.stop()
output.release()
muxer = null
staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not remux ${source.name}", e)
false
} finally {
runCatching { muxer?.release() }
extractor.release()
staged.delete()
}
}
}
@@ -0,0 +1,499 @@
package com.droiddeck.launcher.session
import android.graphics.SurfaceTexture
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLExt
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
internal object BootMovieTranscoder {
private const val TAG = "BootMovieTranscoder"
private const val TIMEOUT_US = 10_000L
private const val MAX_FPS = 60
private const val OPUS_RATE = 48_000
class Result(val audioDropped: Boolean)
private class Sample(val data: ByteArray, val timeUs: Long)
private class Audio(val format: MediaFormat, val samples: List<Sample>)
fun canDecode(format: MediaFormat): Boolean = runCatching {
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
}.getOrDefault(false)
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
val audio = if (hadAudio) runCatching { encodeAudio(source) }
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
return Result(audioDropped = hadAudio && !muxedAudio)
}
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(source.path)
(0 until extractor.trackCount).map { it to extractor.getTrackFormat(it) }
.firstOrNull { it.second.getString(MediaFormat.KEY_MIME)?.startsWith(prefix) == true }
} finally {
extractor.release()
}
}
internal class Layout(val width: Int, val height: Int, val x: Int, val y: Int, val contentWidth: Int, val contentHeight: Int)
// Steam fills the screen with its startup movie, so portrait clips are pillarboxed into 16:9 instead of cropped.
internal fun layout(width: Int, height: Int, rotation: Int): Layout {
val (w, h) = if (rotation % 180 != 0) height to width else width to height
fun even(v: Double) = maxOf(2, (v.toInt() / 2) * 2)
if (w >= h) {
val scale = minOf(1.0, 1280.0 / w, 720.0 / h)
val cw = even(w * scale)
val ch = even(h * scale)
return Layout(cw, ch, 0, 0, cw, ch)
}
val ch = even(minOf(h, 720).toDouble())
val cw = even(w * ch.toDouble() / h)
val canvas = even(ch * 16 / 9.0)
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
}
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
if (rate == OPUS_RATE) return pcm
val frames = pcm.size / channels
val outFrames = (frames.toLong() * OPUS_RATE / rate).toInt()
val out = ShortArray(outFrames * channels)
for (i in 0 until outFrames) {
val pos = i.toDouble() * rate / OPUS_RATE
val i0 = pos.toInt().coerceAtMost(frames - 1)
val i1 = (i0 + 1).coerceAtMost(frames - 1)
val frac = pos - i0
for (c in 0 until channels) {
val a = pcm[i0 * channels + c]
val b = pcm[i1 * channels + c]
out[i * channels + c] = (a + (b - a) * frac).toInt().toShort()
}
}
return out
}
private fun encodeAudio(source: File): Audio? {
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
var encoder: MediaCodec? = null
try {
extractor.setDataSource(source.path)
extractor.selectTrack(track)
var rate = inFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
var channels = inFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
val dec = MediaCodec.createDecoderByType(inFormat.getString(MediaFormat.KEY_MIME)!!).also { decoder = it }
dec.configure(inFormat, null, null, 0)
dec.start()
val pcm = ByteArrayOutputStream()
val info = MediaCodec.BufferInfo()
var inputDone = false
while (true) {
if (!inputDone) {
val index = dec.dequeueInputBuffer(TIMEOUT_US)
if (index >= 0) {
val size = extractor.readSampleData(dec.getInputBuffer(index)!!, 0)
if (size < 0) {
dec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
dec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
val index = dec.dequeueOutputBuffer(info, TIMEOUT_US)
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
val format = dec.outputFormat
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
if (format.containsKey(MediaFormat.KEY_PCM_ENCODING) &&
format.getInteger(MediaFormat.KEY_PCM_ENCODING) != android.media.AudioFormat.ENCODING_PCM_16BIT) return null
} else if (index >= 0) {
if (info.size > 0) {
val bytes = ByteArray(info.size)
dec.getOutputBuffer(index)!!.apply { position(info.offset); get(bytes) }
pcm.write(bytes)
}
dec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
}
}
if (channels !in 1..2) return null
val raw = pcm.toByteArray()
val shorts = ShortArray(raw.size / 2)
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
val samples = resample(shorts, channels, rate)
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
val name = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(format) ?: return null
val enc = MediaCodec.createByCodecName(name).also { encoder = it }
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
enc.start()
val packets = ArrayList<Sample>()
var outFormat: MediaFormat? = null
var offset = 0
var encodeInputDone = false
while (true) {
if (!encodeInputDone) {
val index = enc.dequeueInputBuffer(TIMEOUT_US)
if (index >= 0) {
val buffer = enc.getInputBuffer(index)!!
buffer.clear()
val count = minOf(buffer.remaining() / 2, samples.size - offset) / channels * channels
val timeUs = offset.toLong() / channels * 1_000_000L / OPUS_RATE
if (count == 0) {
enc.queueInputBuffer(index, 0, 0, timeUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
encodeInputDone = true
} else {
buffer.order(ByteOrder.nativeOrder()).asShortBuffer().put(samples, offset, count)
enc.queueInputBuffer(index, 0, count * 2, timeUs, 0)
offset += count
}
}
}
val index = enc.dequeueOutputBuffer(info, TIMEOUT_US)
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
outFormat = enc.outputFormat
} else if (index >= 0) {
if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
val bytes = ByteArray(info.size)
enc.getOutputBuffer(index)!!.apply { position(info.offset); get(bytes) }
packets.add(Sample(bytes, info.presentationTimeUs))
}
enc.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
}
}
return outFormat?.let { Audio(it, packets) }
} finally {
runCatching { decoder?.stop() }
decoder?.release()
runCatching { encoder?.stop() }
encoder?.release()
extractor.release()
}
}
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
val width = layout.width
val height = layout.height
val fps = (inFormat.intOrNull(MediaFormat.KEY_FRAME_RATE) ?: 30).coerceIn(1, MAX_FPS)
val durationUs = if (inFormat.containsKey(MediaFormat.KEY_DURATION)) inFormat.getLong(MediaFormat.KEY_DURATION) else 0L
val minIntervalUs = 1_000_000L / MAX_FPS - 2_000
val encFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_VP8, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, (width.toLong() * height * fps / 10).coerceIn(1_000_000, 8_000_000).toInt())
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
}
val encoderName = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(encFormat)
?: throw IllegalStateException("no VP8 encoder")
val extractor = MediaExtractor()
var encoder: MediaCodec? = null
var decoder: MediaCodec? = null
var egl: EglWindow? = null
var renderer: FrameRenderer? = null
var muxer: MediaMuxer? = null
val staged = File(target.parentFile, target.name + ".convert")
try {
extractor.setDataSource(source.path)
extractor.selectTrack(track)
val enc = MediaCodec.createByCodecName(encoderName).also { encoder = it }
enc.configure(encFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val window = EglWindow(enc.createInputSurface()).also { egl = it }
val frames = FrameRenderer(rotation).also { renderer = it }
enc.start()
inFormat.setInteger(MediaFormat.KEY_ROTATION, 0)
val dec = MediaCodec.createDecoderByType(inFormat.getString(MediaFormat.KEY_MIME)!!).also { decoder = it }
dec.configure(inFormat, frames.surface, null, 0)
dec.start()
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
var videoTrack = -1
var audioTrack = -1
var audioIndex = 0
fun writeAudioUpTo(timeUs: Long) {
if (audioTrack < 0 || audio == null) return
val info = MediaCodec.BufferInfo()
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
val sample = audio.samples[audioIndex++]
info.set(0, sample.data.size, sample.timeUs, 0)
mux.writeSampleData(audioTrack, ByteBuffer.wrap(sample.data), info)
}
}
val decInfo = MediaCodec.BufferInfo()
val encInfo = MediaCodec.BufferInfo()
var inputDone = false
var decoderDone = false
var lastKept = Long.MIN_VALUE
var lastProgress = -1
while (true) {
if (!inputDone) {
val index = dec.dequeueInputBuffer(TIMEOUT_US)
if (index >= 0) {
val size = extractor.readSampleData(dec.getInputBuffer(index)!!, 0)
if (size < 0) {
dec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
dec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
if (!decoderDone) {
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
if (index >= 0) {
val timeUs = decInfo.presentationTimeUs
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
dec.releaseOutputBuffer(index, keep)
if (keep) {
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
frames.draw(layout)
window.swap(timeUs * 1000)
lastKept = timeUs
if (durationUs > 0) {
val percent = (timeUs * 100 / durationUs).toInt().coerceIn(0, 99)
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
}
}
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
enc.signalEndOfInputStream()
decoderDone = true
}
}
}
var encoderDone = false
while (true) {
val index = enc.dequeueOutputBuffer(encInfo, if (decoderDone) TIMEOUT_US else 0)
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) break
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
videoTrack = mux.addTrack(enc.outputFormat)
if (audio != null) audioTrack = runCatching { mux.addTrack(audio.format) }
.onFailure { Log.w(TAG, "WebM muxer refused the Opus track", it) }.getOrDefault(-1)
mux.start()
} else if (index >= 0) {
if (encInfo.size > 0 && encInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
check(videoTrack >= 0) { "encoder produced data before its format" }
writeAudioUpTo(encInfo.presentationTimeUs)
mux.writeSampleData(videoTrack, enc.getOutputBuffer(index)!!, encInfo)
}
enc.releaseOutputBuffer(index, false)
if (encInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { encoderDone = true; break }
}
}
if (encoderDone) break
}
writeAudioUpTo(Long.MAX_VALUE)
mux.stop()
mux.release()
muxer = null
if (!staged.renameTo(target)) throw IllegalStateException("could not move the converted movie")
onProgress(100)
return audioTrack >= 0
} finally {
runCatching { muxer?.release() }
runCatching { decoder?.stop() }
decoder?.release()
runCatching { encoder?.stop() }
encoder?.release()
renderer?.release()
egl?.release()
extractor.release()
staged.delete()
}
}
private class EglWindow(private val surface: Surface) {
private val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
private val context: android.opengl.EGLContext
private val window: android.opengl.EGLSurface
init {
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val attributes = intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL_RECORDABLE_ANDROID, 1, EGL14.EGL_NONE,
)
val configs = arrayOfNulls<EGLConfig>(1)
val count = IntArray(1)
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) { "no recordable EGL config" }
context = EGL14.eglCreateContext(display, configs[0], EGL14.EGL_NO_CONTEXT,
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0)
window = EGL14.eglCreateWindowSurface(display, configs[0], surface, intArrayOf(EGL14.EGL_NONE), 0)
check(EGL14.eglMakeCurrent(display, window, window, context)) { "eglMakeCurrent failed" }
}
fun swap(timeNs: Long) {
EGLExt.eglPresentationTimeANDROID(display, window, timeNs)
EGL14.eglSwapBuffers(display, window)
}
fun release() {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
EGL14.eglDestroySurface(display, window)
EGL14.eglDestroyContext(display, context)
EGL14.eglReleaseThread()
EGL14.eglTerminate(display)
surface.release()
}
companion object {
private const val EGL_RECORDABLE_ANDROID = 0x3142
}
}
private class FrameRenderer(rotation: Int) {
private val thread = HandlerThread("boot-movie-frames").apply { start() }
private val lock = Object()
private var available = false
private val texture = IntArray(1).also { GLES20.glGenTextures(1, it, 0) }[0]
private val surfaceTexture: SurfaceTexture
val surface: Surface
private val program: Int
private val transform = FloatArray(16)
private val positions: FloatBuffer
private val coords: FloatBuffer = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
init {
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texture)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
surfaceTexture = SurfaceTexture(texture)
surfaceTexture.setOnFrameAvailableListener({
synchronized(lock) { available = true; lock.notifyAll() }
}, Handler(thread.looper))
surface = Surface(surfaceTexture)
program = link(VERTEX, FRAGMENT)
val radians = Math.toRadians(rotation.toDouble())
val cos = Math.cos(radians).toFloat()
val sin = Math.sin(radians).toFloat()
val corners = floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
positions = floats(*FloatArray(8) { i ->
val x = corners[i - i % 2]
val y = corners[i - i % 2 + 1]
if (i % 2 == 0) x * cos + y * sin else -x * sin + y * cos
})
}
fun awaitFrame(): Boolean {
synchronized(lock) {
val deadline = System.currentTimeMillis() + 2_500
while (!available) {
val wait = deadline - System.currentTimeMillis()
if (wait <= 0) return false
lock.wait(wait)
}
available = false
}
surfaceTexture.updateTexImage()
return true
}
fun draw(layout: Layout) {
surfaceTexture.getTransformMatrix(transform)
GLES20.glViewport(0, 0, layout.width, layout.height)
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glViewport(layout.x, layout.y, layout.contentWidth, layout.contentHeight)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texture)
val position = GLES20.glGetAttribLocation(program, "aPosition")
val coord = GLES20.glGetAttribLocation(program, "aCoord")
GLES20.glEnableVertexAttribArray(position)
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, positions)
GLES20.glEnableVertexAttribArray(coord)
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, coords)
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
}
fun release() {
surface.release()
surfaceTexture.release()
GLES20.glDeleteProgram(program)
GLES20.glDeleteTextures(1, intArrayOf(texture), 0)
thread.quitSafely()
}
private fun floats(vararg values: Float): FloatBuffer =
ByteBuffer.allocateDirect(values.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
private fun link(vertex: String, fragment: String): Int {
fun compile(type: Int, code: String): Int = GLES20.glCreateShader(type).also {
GLES20.glShaderSource(it, code)
GLES20.glCompileShader(it)
val ok = IntArray(1)
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, ok, 0)
check(ok[0] != 0) { GLES20.glGetShaderInfoLog(it) }
}
return GLES20.glCreateProgram().also {
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
GLES20.glLinkProgram(it)
val ok = IntArray(1)
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, ok, 0)
check(ok[0] != 0) { GLES20.glGetProgramInfoLog(it) }
}
}
companion object {
private val VERTEX = """
attribute vec4 aPosition;
attribute vec4 aCoord;
uniform mat4 uTransform;
varying vec2 vCoord;
void main() {
gl_Position = aPosition;
vCoord = (uTransform * aCoord).xy;
}
""".trimIndent()
private val FRAGMENT = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vCoord);
}
""".trimIndent()
}
}
}
@@ -711,9 +711,71 @@ object ComponentsManager {
saveState(context, state)
}
class StoredPackage(val pkg: Package, val inUse: Boolean)
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
private fun packagesInUse(state: JSONObject): Set<String> {
val names = HashSet<String>()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add)
}
}
val profiles = state.optJSONObject("profiles")
for (scope in profiles?.keys() ?: emptyList<String>().iterator()) {
val profile = profiles?.optJSONObject(scope) ?: continue
COMPONENTS.forEach { comp -> profile.optString(comp).takeIf { it.isNotEmpty() }?.let(names::add) }
}
return names
}
private fun originalKept(state: JSONObject, installed: Map<String, Proton>, protonId: String, comp: String, version: String): Boolean {
val current = installed[protonId]?.let { safeName(it.version) == version } == true
val queued = state.optJSONObject("queued")?.optJSONObject(protonId)?.optJSONObject(comp)
?.takeIf { it.optString("kind") == "original" }?.let { safeName(it.optString("protonVersion")) == version } == true
return current || queued
}
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
val used = packagesInUse(state)
val installed = protons(context).associateBy { it.id }
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
val f = File(version, "$comp.wcp")
if (!f.isFile) null else StoredOriginal(
proton.name, installed[proton.name]?.name, comp, version.name, f.length(),
originalKept(state, installed, proton.name, comp, version.name),
)
}
}.orEmpty()
}.orEmpty()
Stored(packages(context).map { StoredPackage(it, it.file in used) }, originals)
}
fun deleteUnusedOriginal(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
val state = loadState(context)
if (originalKept(state, protons(context).associateBy { it.id }, protonId, comp, protonVersion)) return false
if (comp !in COMPONENTS || listOf(protonId, protonVersion).any { it.isEmpty() || '/' in it || it == "." || it == ".." }) return false
val f = File(originalsDir(context), "$protonId/$protonVersion/$comp.wcp")
if (!f.isFile || !f.delete()) return false
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
f.parentFile?.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
true
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val queued = state.sub("queued")
for (pid in queued.keys()) {
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
@@ -867,8 +929,7 @@ object ComponentsManager {
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
import com.github.luben.zstd.ZstdInputStream
@@ -620,16 +621,27 @@ object EsyncPacks {
val label = context.getString(com.droiddeck.launcher.R.string.esync_fetching)
onProgress?.invoke(label, -1)
var reported = -1
val fetched = FileOutputStream(part).use { output ->
fetch(entry.asset.url, entry.asset.size, output) { done ->
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
if (percent != reported) {
reported = percent
onProgress?.invoke(label, percent)
val sources = DownloadSource.candidates(entry.asset.url)
var fetched = false
for ((index, source) in sources.withIndex()) {
if (index > 0) Log.i(TAG, "retrying ${entry.asset.url} via $source")
reported = -1
fetched = FileOutputStream(part).use { output ->
fetch(source, entry.asset.size, output) { done ->
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
if (percent != reported) {
reported = percent
onProgress?.invoke(label, percent)
}
}
}
if (fetched) {
DownloadSource.succeeded(source)
break
}
}
if (!fetched) {
DownloadSource.failed()
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to "download"))
return false
}
@@ -836,8 +848,17 @@ object EsyncPacks {
}
private fun fetchBytes(url: String, limit: Long): ByteArray? {
val out = ByteArrayOutputStream()
return if (fetch(url, limit, out, null)) out.toByteArray() else null
val sources = DownloadSource.candidates(url)
for ((index, source) in sources.withIndex()) {
if (index > 0) Log.i(TAG, "retrying $url via $source")
val out = ByteArrayOutputStream()
if (fetch(source, limit, out, null)) {
DownloadSource.succeeded(source)
return out.toByteArray()
}
}
DownloadSource.failed()
return null
}
private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean =
@@ -0,0 +1,209 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.os.PerformanceHintManager
import android.os.PowerManager
import android.os.Process
import android.system.Os
import android.system.OsConstants
import android.util.Log
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import kotlin.math.abs
/**
* The game's busiest threads named to Android's performance hint service (ADPF) for a session.
*
* Qualcomm's WALT moves a thread to the prime cores only once it fills a mid core (the Odin 3's
* sched_upmigrate is 95), and a game thread that waits on its workers or the GPU for part of each
* frame never does: Factorio's update thread sat at ~68% on cores 0-5 with 6-7 idle, and ran 60%
* faster pinned to 6-7. An app may not raise a thread's uclamp (EPERM without CAP_SYS_NICE), but a
* hint session may name any thread of its UID - the guest's are - and one reported behind its
* target is boosted by the power HAL onto the prime cores: on the Odin 3 a 70%-busy thread of a
* child process went from cores 0-5 to 6-7 and ran 1.7x faster within a second.
*
* Once a second the busiest threads of this UID are read from /proc; one busy more than [ENTER] of
* the time (averaged with the second before, so a loading spike does not count) joins the session,
* and leaves it under [LEAVE] - well below, since it runs faster where it was moved. While the
* session has threads it reports them behind target, which is what asks for the boost: the reports
* carry no frame timing, the busy share is the signal (a capped game's frame interval looks the same
* as a slow one's). Nothing is boosted while the device is critically hot or saves power.
*
* Nor while the game holds the panel's rate or a fraction of it ([holdsPace]): there is nothing to
* gain, and it cost frame pacing - Serious Sam capped at 120 fps ran its boosted main thread on prime
* cores the governor kept at 2.2 GHz, slower than a 3.5 GHz mid core, and 23-25% of its frames fell
* under 90 fps against 2-7% unboosted. A game with no frames (Factorio's benchmark, a loading screen)
* or one falling behind the panel is boosted; one holding pace keeps what it has for [PACE_LEAVE_S]
* seconds and is then let go.
*/
object GameThreadHints {
private const val TAG = "GameThreadHints"
private const val TARGET_NS = 10_000_000L
private const val REPORT_MS = 50L
private const val SCAN_EVERY = 20 // reports, so once a second
private const val ENTER = 0.6
private const val LEAVE = 0.15 // a capped game's main thread idles to ~30% on a prime core; under 0.3 it hopped back and forth
private const val MAX_THREADS = 2 // one per prime core; a third would be sped up in place instead
private const val TOP_PROCESSES = 3
private const val PACE_WINDOW_S = 3
private const val PACE_LEAVE_S = 10
private const val PACE_TOLERANCE = 0.08
/** Busy, but not the game: the app itself and the Steam client, whose UI would otherwise hold the prime cores. */
private val NOT_GAMES = setOf("steam", "steamwebhelper")
private var worker: Thread? = null
/** At a session's start. The line is for the log. */
@Synchronized
fun start(context: Context): String {
if (!SessionPrefs.gameThreadHints(context)) return "game thread hints: off"
// Measured on the Odin 3 (CQ8725S) only; another HAL may boost differently, or not at all.
if (Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S") return "game thread hints: Odin 3 only"
val hm = context.getSystemService(PerformanceHintManager::class.java)
?: return "game thread hints: no PerformanceHintManager"
val pm = context.getSystemService(PowerManager::class.java)
worker?.interrupt()
worker = Thread({ run(hm, pm) }, "game-thread-hints").apply { isDaemon = true; start() }
return "game thread hints: watching"
}
/** At a session's end. */
@Synchronized
fun stop() {
worker?.interrupt()
worker = null
}
private fun run(hm: PerformanceHintManager, pm: PowerManager) {
val scanner = Scanner()
var session: PerformanceHintManager.Session? = null
var boosted = emptySet<Int>()
val frames = ArrayDeque<Long>()
var pacedFor = 0
var tick = 0
try {
while (!Thread.currentThread().isInterrupted) {
if (tick++ % SCAN_EVERY == 0) {
val busy = scanner.sample()
frames.addLast(WaylandCompositor.gameFrameCount())
if (frames.size > PACE_WINDOW_S + 1) frames.removeFirst()
val fps = (frames.last() - frames.first()).toDouble() / (frames.size - 1).coerceAtLeast(1)
val paced = frames.size > PACE_WINDOW_S && holdsPace(fps, SessionState.refreshHz.toDouble())
pacedFor = if (paced) pacedFor + 1 else 0
// Not SEVERE: the Odin 3 reports it once its skin reaches 50 C, a couple of minutes into any game.
val spare = pm.isPowerSaveMode || pm.currentThermalStatus >= PowerManager.THERMAL_STATUS_CRITICAL
val next = when {
spare || pacedFor >= PACE_LEAVE_S -> emptySet()
paced -> pick(boosted, busy.filterKeys { it in boosted })
else -> pick(boosted, busy)
}
if (next != boosted) {
session = update(hm, session, next)
// No session means a thread exited in between: tried again next second.
boosted = if (session == null) emptySet() else next
Log.i(TAG, (if (session == null && next.isNotEmpty()) "no session for " else "boosting ") +
next.joinToString { "${readName("/proc/$it/comm")}/$it ${((busy[it] ?: 0.0) * 100).toInt()}%" }.ifEmpty { "nothing" } +
" (game ${fps.toInt()} fps${if (paced) ", holding pace" else ""})")
}
}
session?.reportActualWorkDuration(TARGET_NS * 3 / 2)
Thread.sleep(REPORT_MS)
}
} catch (_: InterruptedException) {
} catch (t: Throwable) {
Log.w(TAG, "stopped", t)
} finally {
session?.close()
}
}
/** A new session over [tids] in place of [session]; null for none. The set changes seldom, so no setThreads. */
private fun update(hm: PerformanceHintManager, session: PerformanceHintManager.Session?, tids: Set<Int>): PerformanceHintManager.Session? {
session?.close()
if (tids.isEmpty()) return null
return try {
hm.createHintSession(tids.toIntArray(), TARGET_NS)
} catch (t: RuntimeException) {
Log.w(TAG, "createHintSession", t)
null
}
}
/**
* Whether a game presenting [fps] frames a second keeps up with a [refreshHz] panel: within
* [PACE_TOLERANCE] of the rate or of a half, a third or a quarter of it (a 60 fps limiter on 120 Hz).
* A game with next to no frames (a headless benchmark, a loading screen) does not.
*/
// ponytail: a CPU-bound game that happens to run at ~60 fps on a 120 Hz panel reads as limited; narrow the band if that shows up
internal fun holdsPace(fps: Double, refreshHz: Double): Boolean =
fps >= 10 && refreshHz > 0 && (1..4).any { k -> abs(fps - refreshHz / k) < PACE_TOLERANCE * refreshHz / k }
/**
* The threads to boost next: those already boosted that are still above [LEAVE], then new ones
* above [ENTER], busiest first, at most [MAX_THREADS].
*/
internal fun pick(boosted: Set<Int>, busy: Map<Int, Double>): Set<Int> =
busy.entries
.filter { (tid, share) -> share >= if (tid in boosted) LEAVE else ENTER }
.sortedWith(compareByDescending<Map.Entry<Int, Double>> { it.key in boosted }.thenByDescending { it.value })
.take(MAX_THREADS)
.map { it.key }
.toSet()
private fun readName(path: String) = try { File(path).readText().trim() } catch (_: Exception) { "?" }
/** Busy shares of this UID's threads since the last sample: /proc shows an app only its own processes. */
private class Scanner {
private val self = Process.myPid()
private val hz = Os.sysconf(OsConstants._SC_CLK_TCK).toDouble()
private var lastNs = 0L
private var lastProcess = HashMap<Int, Long>()
private var lastThread = HashMap<Int, Long>()
private var lastBusy = HashMap<Int, Double>()
/** Only the busiest few processes have their threads read, so a second costs a few dozen small reads. */
fun sample(): Map<Int, Double> {
val now = System.nanoTime()
val seconds = (now - lastNs) / 1e9
lastNs = now
val process = HashMap<Int, Long>()
File("/proc").list()?.forEach { entry ->
val pid = entry.toIntOrNull() ?: return@forEach
cpuTicks("/proc/$pid/stat")?.let { process[pid] = it }
}
val top = process.entries
.map { (pid, ticks) -> pid to ticks - (lastProcess[pid] ?: ticks) }
.filter { (pid, delta) -> delta > 0 && pid != self }
.sortedByDescending { it.second }
.asSequence()
.filter { (pid, _) -> readName("/proc/$pid/comm") !in NOT_GAMES }
.take(TOP_PROCESSES)
.toList()
lastProcess = process
val thread = HashMap<Int, Long>()
val busy = HashMap<Int, Double>()
for ((pid, _) in top) {
File("/proc/$pid/task").list()?.forEach { entry ->
val tid = entry.toIntOrNull() ?: return@forEach
val ticks = cpuTicks("/proc/$pid/task/$tid/stat") ?: return@forEach
thread[tid] = ticks
lastThread[tid]?.let { before -> busy[tid] = ((ticks - before) / hz / seconds + (lastBusy[tid] ?: 0.0)) / 2 }
}
}
lastThread = thread
lastBusy = busy
return busy
}
/** utime + stime of a /proc stat line; fields counted after the command's closing parenthesis. */
private fun cpuTicks(path: String): Long? = try {
val line = File(path).readText()
val fields = line.substring(line.lastIndexOf(')') + 2).split(' ')
fields[11].toLong() + fields[12].toLong()
} catch (_: Exception) {
null
}
}
}
@@ -29,7 +29,7 @@ object ProtonExtras {
val tools = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "Droid-Deck/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
)
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
@@ -167,7 +167,7 @@ object ProtonExtras {
onProgress(downloading, 0)
var downloaded = false
for (attempt in 0 until 5) {
downloaded = Downloader.downloadFile(asset.url, archive, true) { fraction ->
downloaded = Downloader.downloadFile(asset.url, archive, true, asset.sha256) { fraction ->
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
}
if (downloaded) break
@@ -192,7 +192,7 @@ object ProtonExtras {
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
val expected = Downloader.downloadDirect(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return context.getString(R.string.pextra_checksum_unreadable)
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
@@ -24,6 +24,22 @@ object QuestExperimental {
fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
fun isQuest2(): Boolean = quest2(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
fun defaultAndroidDriver(): String? = when {
!isSupported() -> null
isQuest2() -> DRIVER_A6XX
else -> DRIVER
}
fun applyAutoDrivers(context: Context): Boolean {
if (SessionPrefs.gpuDriverMode(context) != SessionPrefs.GPU_DRIVERS_AUTO) return false
val driver = defaultAndroidDriver() ?: return false
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
.putString("androidDriver", driver)
.putString("linuxDriver", "")
.apply()
return true
}
fun initialize(context: Context) {
if (!isSupported()) return
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
@@ -4,6 +4,9 @@ import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
@@ -21,15 +24,18 @@ class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var phone: Motors? = null
private val playing = HashMap<Int, Motors>()
private val sent = HashMap<Int, Sent>()
private val effects = HashMap<Int, Effect>()
private val controllers = HashMap<Int, Motors?>()
private var preferences: SharedPreferences? = null
private var enabled = false
private var motorThread: HandlerThread? = null
private var handler: Handler? = null
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == null || key == "rumble") refreshEnabled()
}
internal var controllerMotors: (Int) -> Motors? = ::lookupController
internal var motorLooper: Looper? = null
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
@@ -42,6 +48,7 @@ class RumbleComponent : SessionPart() {
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
handler = Handler(motorLooper ?: HandlerThread("rumble-motors").also { motorThread = it; it.start() }.looper)
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
refreshEnabled()
server = s
@@ -66,6 +73,9 @@ class RumbleComponent : SessionPart() {
preferences = null
enabled = false
cancelAll()
handler = null
motorThread?.quitSafely()
motorThread = null
phone = null
controllers.clear()
val s = server
@@ -81,24 +91,39 @@ class RumbleComponent : SessionPart() {
val previous = playing[slot]
if (target !== previous) {
previous?.cancel()
sent.remove(slot)
forget(slot)
if (target == null) playing.remove(slot) else playing[slot] = target
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); forget(slot); return }
val handler = handler ?: return
val effect = effects.getOrPut(slot) { Effect(slot) }
effect.want(strong, weak, ms)
val now = SystemClock.uptimeMillis()
val last = sent[slot]
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
val duration = ms.toLong().coerceIn(1L, 5000L)
if (effect.playing(now)) return
handler.removeCallbacks(effect)
val wait = effect.at + MIN_GAP_MS - now
if (wait > 0) handler.postDelayed(effect, wait) else play(effect)
}
@Synchronized private fun play(effect: Effect) {
val target = playing[effect.slot]
if (target == null || effects[effect.slot] !== effect) return
val duration = if (effect.hold) HOLD_EFFECT_MS else effect.ms.toLong().coerceIn(1L, 5000L)
try {
target.play(strong, weak, duration)
sent[slot] = Sent(strong, weak, now, now + duration)
target.play(effect.strong, effect.weak, duration)
effect.played(SystemClock.uptimeMillis(), duration)
if (effect.hold) handler?.postDelayed(effect, KEEPALIVE_MS)
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
}
private fun forget(slot: Int) {
effects.remove(slot)?.let { handler?.removeCallbacks(it) }
}
private fun target(slot: Int): Motors? {
val id = PadBridge.controllerForSlot(slot)
if (id == PadBridge.NO_CONTROLLER) return phone
@@ -109,10 +134,36 @@ class RumbleComponent : SessionPart() {
private fun cancelAll() {
playing.values.toSet().forEach { it.cancel() }
playing.clear()
sent.clear()
effects.values.forEach { handler?.removeCallbacks(it) }
effects.clear()
}
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
private inner class Effect(val slot: Int) : Runnable {
var strong = 0
var weak = 0
var ms = 0
var at = -MIN_GAP_MS
private var playedStrong = 0
private var playedWeak = 0
private var playedMs = 0
private var until = 0L
val hold get() = ms == HOLD_MS
fun want(strong: Int, weak: Int, ms: Int) { this.strong = strong; this.weak = weak; this.ms = ms }
fun playing(now: Long) = playedMs != 0 && playedStrong == strong && playedWeak == weak && playedMs == ms &&
(hold || now < until)
fun played(now: Long, duration: Long) {
playedStrong = strong
playedWeak = weak
playedMs = ms
at = now
until = now + duration
}
override fun run() = play(this)
}
internal interface Motors {
val name: String
@@ -137,20 +188,35 @@ class RumbleComponent : SessionPart() {
any = true
}
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
if (any) manager.vibrate(combined.combine()) else manager.cancel()
if (any) manager.vibrate(combined.combine()) else cancel()
}
// Some vendors' input-device vibrator HALs (Samsung's) don't halt a Bluetooth pad's running
// effect on cancel(), but a new effect always replaces it.
override fun cancel() {
val supersede = CombinedVibration.startParallel()
ids.forEach { supersede.addVibrator(it, VibrationEffect.createOneShot(1L, 1)) }
manager.vibrate(supersede.combine())
manager.cancel()
}
override fun cancel() = manager.cancel()
}
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
override fun cancel() {
vibrator.vibrate(VibrationEffect.createOneShot(1L, 1))
vibrator.cancel()
}
}
companion object {
private const val TAG = "SessionService"
const val NAME = "droiddeck-rumble"
const val MIN_GAP_MS = 50L
// Must match RUMBLE_HOLD_MS in fakeinput_steam.cpp.
const val HOLD_MS = 0xFFFF
const val HOLD_EFFECT_MS = 2000L
const val KEEPALIVE_MS = 1500L
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
@@ -119,7 +119,7 @@ object SessionArtifacts {
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
record.files.forEach { (f, s) ->
lines.append(s.length).append('\t').append(s.modified).append('\t')
.append(f.relativeTo(dir).path).append('\n')
.append(f.relativeTo(dir).invariantSeparatorsPath).append('\n')
}
tree.writeText(lines.toString())
true
@@ -103,6 +103,7 @@ object SessionEvents {
if (!SessionState.running || SessionState.guestPid <= 1 || !SessionState.firstFrameSeen || SessionState.suspended) return
if (SessionState.phase != SessionPhase.READY) {
transition(SessionPhase.READY, "session.ready")
Thread({ com.droiddeck.launcher.gpu.AutoDriverTrial.sessionReady() }, "gpu-driver-trial").start()
}
}
@@ -71,6 +71,8 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
@@ -104,6 +106,7 @@ object SessionFiles {
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
val vr = listOf(
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
"droiddeck/bin/linuxarm64/xr-display-mode",
"droiddeck/driver.vrdrivermanifest",
"droiddeck/resources/settings/default.vrsettings",
"layer/libVkLayer_droiddeck_vr.so",
@@ -185,27 +188,29 @@ object SessionFiles {
}
val startupMovieDir = File(root, "root/.local/share/Steam/config/uioverrides/movies")
val bigPictureMovieInstalled = stageStartupMovie(
context,
startupMovieDir,
"bigpicture_startup.webm",
)
stageStartupMovie(context, startupMovieDir, "steam_os_startup.webm")
val startupMovie = BootMovie.prepare(context)
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
}
}
private fun stageStartupMovie(context: Context, directory: File, name: String): Boolean {
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
val movie = File(directory, name)
val stamp = File(directory, ".$name.source")
val identity = "${source.absolutePath}\n${source.length()}\n${source.lastModified()}"
if (movie.length() == source.length() && runCatching { stamp.readText() }.getOrNull() == identity) return true
val staged = File(directory, "$name.staged")
var installed = false
try {
directory.mkdirs()
context.assets.open("steam-startup/droiddeck-startup.webm").use { input ->
source.inputStream().use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
if (installed) FileUtils.writeString(stamp, identity)
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
@@ -58,7 +58,7 @@ object SessionLogShare {
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
}
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).invariantSeparatorsPath, f); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
@@ -10,7 +10,7 @@ import java.util.Locale
object SessionPaths {
private const val TAG = "SessionPaths"
const val KEEP_SESSIONS = 30
const val KEEP_SESSIONS = 3
const val TOOLS_DIR = "tools"
@@ -104,6 +104,23 @@ object SessionPrefs {
prefs(context).edit().putBoolean("storesShowMature", show).apply()
}
fun storesSort(context: Context): String =
prefs(context).getString("storesSort", null)?.takeIf { it in STORES_SORTS } ?: STORES_SORTS.first()
fun setStoresSort(context: Context, sort: String) {
prefs(context).edit().putString("storesSort", if (sort in STORES_SORTS) sort else STORES_SORTS.first()).apply()
}
fun storesView(context: Context): String =
prefs(context).getString("storesView", null)?.takeIf { it in STORES_VIEWS } ?: STORES_VIEWS.first()
fun setStoresView(context: Context, view: String) {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
}
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "list")
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
@@ -130,6 +147,18 @@ object SessionPrefs {
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
fun lowLatencyController(context: Context): Boolean = prefs(context).getBoolean("lowLatencyController", false)
fun setLowLatencyController(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("lowLatencyController", on).apply()
}
fun lowLatencyTouch(context: Context): Boolean = prefs(context).getBoolean("lowLatencyTouch", false)
fun setLowLatencyTouch(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("lowLatencyTouch", on).apply()
}
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
fun setBackActionsInverted(context: Context, inverted: Boolean) {
@@ -172,6 +201,7 @@ object SessionPrefs {
const val GPU_DRIVERS_AUTO = "auto"
const val GPU_DRIVERS_MANUAL = "manual"
const val GPU_DRIVERS_MIX = "mix"
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
@@ -195,6 +225,15 @@ object SessionPrefs {
}
fun picturePosition(context: Context): String =
prefs(context).getString("picturePosition", "center")
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
fun setPicturePosition(context: Context, position: String) {
prefs(context).edit().putString("picturePosition",
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
}
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
@@ -320,6 +359,9 @@ object SessionPrefs {
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
}
/** Whether the game's busiest threads are named to the power HAL for a boost (GameThreadHints). */
fun gameThreadHints(context: Context): Boolean = prefs(context).getBoolean("gameThreadHints", true)
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
fun setProotNoSeccomp(context: Context, on: Boolean) {
@@ -457,6 +499,16 @@ object SessionPrefs {
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
fun bootMovie(context: Context): String =
prefs(context).getString("bootMovie", BootMovie.DROIDDECK_30) ?: BootMovie.DROIDDECK_30
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
fun setBootMovieCustomName(context: Context, name: String?) {
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
}
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
fun setRunSteamAtStartup(context: Context, on: Boolean) {
@@ -71,6 +71,8 @@ class SessionService : Service() {
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
private var activityVisible = true
private var xrActivityVisible = false
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible, screenOn, xrActivityVisible)
private var screenOn = true
private var manualPauseRequested = false
private var steamSleepToken: String? = null
@@ -170,6 +172,17 @@ class SessionService : Service() {
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_XR_ACTIVITY_VISIBLE -> {
xrActivityVisible = true
resumeSession(panelVisible = false)
return START_NOT_STICKY
}
ACTION_XR_ACTIVITY_HIDDEN -> {
xrActivityVisible = false
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_SUSPEND_POLICY_CHANGED -> {
if (!SessionState.running) return START_NOT_STICKY
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -205,6 +218,7 @@ class SessionService : Service() {
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
activityVisible = true
xrActivityVisible = false
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
suspendDownloadNow = false
@@ -278,6 +292,7 @@ class SessionService : Service() {
private fun runSession(gen: Int) {
SessionTerminal.clear()
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()
try {
LinuxRuntime.writeAccounts(this)
} catch (e: Exception) {
@@ -299,6 +314,7 @@ class SessionService : Service() {
val sessionLog = File(sessionDir, "session.log")
Log.i(TAG, GpuClockPin.start(this))
Log.i(TAG, GameThreadHints.start(this))
val size = SessionState.outputSize
val guest = ArrayList<String>()
@@ -522,9 +538,17 @@ class SessionService : Service() {
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
val linuxDriverId = SessionPrefs.linuxDriver(this)
SessionState.runtimeDriver = linuxDriverId
com.droiddeck.launcher.gpu.AutoDriverTrial.sessionStarting(
this,
com.droiddeck.launcher.wayland.CompositorHost.displayChoice.takeIf { com.droiddeck.launcher.wayland.CompositorHost.isStarted }
?: SessionPrefs.androidDriver(this),
linuxDriverId,
)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
guest.add("ZINK_DEBUG=compact")
@@ -610,6 +634,7 @@ class SessionService : Service() {
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
logControllersAtStart()
com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(SessionPrefs.lowLatencyController(this), SessionPrefs.lowLatencyTouch(this))
if (uinput) {
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) {
@@ -918,8 +943,8 @@ class SessionService : Service() {
Log.i(TAG, "queued Steam game $gameId for the running client")
}
private fun resumeSession() {
activityVisible = true
private fun resumeSession(panelVisible: Boolean = true) {
if (panelVisible) activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
@@ -1110,11 +1135,11 @@ class SessionService : Service() {
private fun updateSuspendPolicy() {
if (!SessionState.running) return
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) {
manualPauseRequested = true
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
val hidden = !hasVisibleSurface
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
@@ -1136,6 +1161,7 @@ class SessionService : Service() {
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"xrActivityVisible" to xrActivityVisible,
"nativeSleep" to (nativeSleepToken != null),
))
if (shouldSuspend) {
@@ -1193,7 +1219,7 @@ class SessionService : Service() {
}
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
@@ -1304,12 +1330,14 @@ class SessionService : Service() {
if (!SessionState.running) return
SessionState.running = false
}
xrActivityVisible = false
updateDownloadMonitoring()
val stoppedGen = sessionGen
val stoppedSession = SessionState.sessionId
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
GpuClockPin.stop(this)
GameThreadHints.stop()
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
suspendOperationPending = false
launchWatcher?.stopWatching()
@@ -1356,8 +1384,8 @@ class SessionService : Service() {
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (pipTask) {
Log.i(TAG, "PiP task dismissed - retaining the session under its background policy")
if (pipTask || xrActivityVisible) {
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
activityVisible = false
updateSuspendPolicy()
return
@@ -1369,6 +1397,7 @@ class SessionService : Service() {
override fun onDestroy() {
stopSession(0)
GameThreadHints.stop() // also when the session never got as far as running
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
@@ -1558,6 +1587,8 @@ class SessionService : Service() {
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
private const val ACTION_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY"
private const val ACTION_STOP_AUXILIARY = "com.droiddeck.launcher.STOP_AUXILIARY"
private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED"
@@ -1617,6 +1648,12 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(action))
}
fun setXrActivityVisible(context: Context, visible: Boolean) {
if (!SessionState.running) return
val action = if (visible) ACTION_XR_ACTIVITY_VISIBLE else ACTION_XR_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action))
}
fun resume(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
@@ -41,6 +41,8 @@ object SessionState {
@Volatile
var running = false
@Volatile
var runtimeDriver: String? = null
var suspended by mutableStateOf(false)
@Volatile var pipActive = false
@Volatile
@@ -0,0 +1,4 @@
package com.droiddeck.launcher.session
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
xrVisible || (panelVisible && screenOn)
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -13,14 +14,14 @@ object SettingsReset {
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"hud", "hudDeck", "backActionsInverted", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9", "picturePosition",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
"questDefaultsV1",
)
@@ -41,6 +42,7 @@ object SettingsReset {
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("steam_touch", Context.MODE_PRIVATE).edit()
.apply { STEAM_TOUCH_KEYS.forEach { remove(it) } }.commit()
DownloadSource.reset(context)
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(context, false)
if (gameProfiles) {
@@ -0,0 +1,123 @@
package com.droiddeck.launcher.storage
import java.io.File
object SteamStorage {
const val SCOUT = 1070560
const val SOLDIER = 1391110
const val SNIPER = 1628350
const val ARM_RUNTIME = 4185400
const val PROTON_ARM = 4427310
const val PROTON_11_ARM = 4628740
const val REDISTRIBUTABLES = 228980
const val FEX = 3127680
const val ARM_TOOL = "droiddeck-proton-arm64"
const val ARM_11_TOOL = "droiddeck-proton-11-arm64"
private val VALVE_TOOLS = setOf(
858280, 961940, 1054830, 1113280, 1245040, 1420170, 1493710, 1580130, 1887720, 2180100, 2348590,
2805730, 3029110, 3658110, 4183110, 4628710, 4690330,
SCOUT, SOLDIER, SNIPER, ARM_RUNTIME, PROTON_ARM, PROTON_11_ARM, REDISTRIBUTABLES, FEX,
)
private val APP_ID = Regex("^\\s*\"appid\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"")
private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"")
data class Manifest(
val file: File, val appId: Int, val name: String, val installDir: String?,
val installed: Boolean, val lastPlayed: Long,
)
fun parseManifest(file: File, text: String): Manifest? {
val id = APP_ID.find(text)?.groupValues?.get(1)?.toIntOrNull()
?: file.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return null
val dir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
return Manifest(
file, id, NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty(), dir,
(STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0) and 4 != 0,
LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L,
)
}
class Listing(val manifests: List<Manifest>, val otherIds: Set<Int>, val complete: Boolean) {
val ids: Set<Int> get() = manifests.map { it.appId }.toSet() + otherIds
}
private val PENDING_MANIFEST = Regex("appmanifest_(\\d+)\\.acf\\..+")
// A partial download has no manifest yet, only a temporary one and its staging folder.
fun manifests(library: File): Listing? {
val apps = File(library, "steamapps")
val names = apps.list() ?: return null
var complete = true
val other = HashSet<Int>()
val read = ArrayList<Manifest>()
for (name in names.sorted()) {
val pending = PENDING_MANIFEST.matchEntire(name)
if (pending != null) {
pending.groupValues[1].toIntOrNull()?.let(other::add)
continue
}
if (!name.startsWith("appmanifest_") || !name.endsWith(".acf")) continue
val f = File(apps, name)
if (!f.isFile) continue
val named = name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull()
val text = runCatching { f.readText() }.getOrNull()
if (text == null) {
if (named != null) other.add(named) else complete = false
continue
}
parseManifest(f, text)?.let(read::add)
}
File(apps, "downloading").list()?.forEach { it.toIntOrNull()?.let(other::add) }
return Listing(read, other, complete)
}
fun isTool(m: Manifest): Boolean = m.appId in VALVE_TOOLS ||
m.installDir?.let { it.startsWith("Proton ") || it.startsWith("SteamLinuxRuntime") } == true
enum class Kind { ARM_PROTON, REQUIRED, OLD_RUNTIME, X86_PROTON }
// DroidDeck's ARM64 Steam client cannot run Valve's x86 Protons, and its own Proton and FEX tools
// replace the scout and soldier container runtimes.
fun kind(m: Manifest): Kind {
val dir = m.installDir.orEmpty()
return when {
m.appId == SCOUT || m.appId == SOLDIER || dir == "SteamLinuxRuntime" || dir == "SteamLinuxRuntime_soldier" -> Kind.OLD_RUNTIME
dir.startsWith("Proton") && "ARM64" in dir -> Kind.ARM_PROTON
m.appId == PROTON_ARM || m.appId == PROTON_11_ARM -> Kind.ARM_PROTON
dir.startsWith("Proton ") -> Kind.X86_PROTON
else -> Kind.REQUIRED
}
}
fun compatMappings(configVdf: String): Map<String, String> {
val start = configVdf.indexOf("\"CompatToolMapping\"")
if (start < 0) return emptyMap()
val open = configVdf.indexOf('{', start)
if (open < 0) return emptyMap()
var depth = 0
var end = configVdf.length
for (i in open until configVdf.length) {
when (configVdf[i]) {
'{' -> depth++
'}' -> if (--depth == 0) { end = i; break }
}
}
return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] }
}
fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1)
fun leftoverId(name: String): Int? = name.toLongOrNull()?.takeIf { it in 1 until (1L shl 31) }?.toInt()
fun isStubPrefix(prefix: File): Boolean = prefix.walkTopDown().onEnter { !java.nio.file.Files.isSymbolicLink(it.toPath()) }
.none { it.isFile && it.name != "steam_autocloud.vdf" && !java.nio.file.Files.isSymbolicLink(it.toPath()) }
}
@@ -0,0 +1,264 @@
package com.droiddeck.launcher.storage
import android.content.Context
import android.os.StatFs
import android.system.Os
import android.system.OsConstants
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.LinuxRuntime
import android.util.Log
import com.droiddeck.launcher.runtime.FlatpakManager
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.SecondaryLibrary
import com.droiddeck.launcher.session.SessionPaths
import java.io.File
enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM, DROIDDECK }
class GameUsage(
val appId: Int, val name: String, val art: File?, val library: String, val internal: Boolean, val lastPlayed: Long,
val game: Long, val prefix: Long, val shaders: Long, val workshop: Long, val shaderDir: File?,
) {
val data: Long get() = prefix + shaders + workshop
val total: Long get() = game + data
}
enum class ToolState { IN_USE, REQUIRED, UNUSED }
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN }
class ToolUsage(
val key: String, val name: String, val bytes: Long, val state: ToolState, val reason: ToolReason,
val games: Int, val isDefault: Boolean, val manifests: List<File>, val dirs: List<File>,
)
class Leftover(val appId: Int, val prefix: File?, val shaders: File?, val bytes: Long, val safe: Boolean)
class ComponentUsage(
val comp: String, val version: String, val bytes: Long, val inUse: Boolean,
val packageFile: String? = null, val protonId: String? = null, val protonName: String? = null,
)
class FlatpakUsage(val id: String, val name: String, val bytes: Long)
class StorageReport(
val at: Long,
val total: Long,
val free: Long,
val buckets: Map<Bucket, Long>,
val prefixes: Long,
val shaders: Long,
val workshop: Long,
val flatpak: Long,
val games: List<GameUsage>,
val tools: List<ToolUsage>,
val leftovers: List<Leftover>,
val logs: List<File>,
val logBytes: Long,
val components: List<ComponentUsage>,
val flatpakApps: List<FlatpakUsage>,
val flatpakRuntimes: Long,
val otherPrefixes: Long,
val leftoversChecked: Boolean,
) {
val droiddeck: Long get() = buckets.values.sum()
val installedShaders: Long get() = games.sumOf { it.shaders }
val unusedTools: List<ToolUsage> get() = tools.filter { it.state == ToolState.UNUSED }
val gamesOnSd: Int get() = games.count { !it.internal }
val unusedComponents: List<ComponentUsage> get() = components.filter { !it.inUse }
val flatpakCache: Long get() = (flatpak - flatpakApps.sumOf { it.bytes } - flatpakRuntimes).coerceAtLeast(0)
}
object StorageScan {
private class Walker {
private val seen = HashSet<Long>()
val known = HashMap<String, Long>()
fun size(file: File): Long {
known[file.path]?.let { return it }
val st = try { Os.lstat(file.path) } catch (e: Exception) { return 0 }
if (st.st_nlink > 1 && !OsConstants.S_ISDIR(st.st_mode)) {
if (!seen.add(st.st_ino xor (st.st_dev shl 40))) return 0
}
var total = st.st_blocks * 512
if (OsConstants.S_ISDIR(st.st_mode)) file.list()?.forEach { total += size(File(file, it)) }
return total
}
fun measure(file: File): Long = size(file).also { known[file.path] = it }
}
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun scan(context: Context): StorageReport {
val walker = Walker()
val files = context.filesDir
val rootfs = LinuxRuntime.rootDir(context)
val steamRoot = steamRoot(context)
val libraries = Library.steamLibraries(context)
val primary = runCatching { steamRoot.canonicalPath }.getOrDefault(steamRoot.path)
val art = runCatching { Library.steamGames(context) }.getOrDefault(emptyList()).associateBy { it.appId }
class Lib(val root: File, val label: String, val internal: Boolean, val data: File, val listing: SteamStorage.Listing?) {
val manifests: List<SteamStorage.Manifest>? get() = listing?.manifests
}
val libs = libraries.map { (root, label) ->
val internal = runCatching { root.canonicalPath }.getOrDefault(root.path) == primary
val data = if (internal) root else SecondaryLibrary.privateRoot(files, root)
Lib(root, label, internal, data, SteamStorage.manifests(root))
}
val everyLibraryRead = libs.all { it.listing?.complete == true }
val everyId = libs.flatMap { it.listing?.ids.orEmpty() }.toSet()
val mappings = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull()
val installedGames = libs.flatMap { it.manifests.orEmpty() }.filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet()
fun gamesOn(tool: String) = mappings.orEmpty().count { (app, name) -> name == tool && app in installedGames }
val defaultTool = mappings?.get("0")
val tools = LinkedHashMap<String, ToolUsage>()
val games = ArrayList<GameUsage>()
for (lib in libs) {
for (m in lib.manifests.orEmpty()) {
val dir = m.installDir
if (SteamStorage.isTool(m)) {
val dirs = listOfNotNull(
dir?.let { File(lib.root, "steamapps/common/$it") },
dir?.let { File(lib.data, "steamapps/common/$it") }.takeIf { !lib.internal },
).filter { it.exists() }
val bytes = dirs.sumOf { walker.measure(it) }
val kind = SteamStorage.kind(m)
val (state, reason) = when (kind) {
SteamStorage.Kind.OLD_RUNTIME -> ToolState.UNUSED to ToolReason.OLD_RUNTIME
SteamStorage.Kind.X86_PROTON -> ToolState.UNUSED to ToolReason.X86_PROTON
SteamStorage.Kind.ARM_PROTON -> ToolState.IN_USE to (if (m.appId == SteamStorage.PROTON_ARM) ToolReason.DEFAULT_PROTON else ToolReason.PROTON)
SteamStorage.Kind.REQUIRED -> ToolState.REQUIRED to when (m.appId) {
SteamStorage.SNIPER -> ToolReason.FEX_RUNTIME
SteamStorage.ARM_RUNTIME -> ToolReason.ARM_RUNTIME
SteamStorage.REDISTRIBUTABLES -> ToolReason.REDISTRIBUTABLES
SteamStorage.FEX -> ToolReason.FEX
else -> ToolReason.STEAM_TOOL
}
}
val count = when (m.appId) {
SteamStorage.PROTON_ARM -> gamesOn(SteamStorage.ARM_TOOL)
SteamStorage.PROTON_11_ARM -> gamesOn(SteamStorage.ARM_11_TOOL)
else -> 0
}
val key = "app:${m.appId}"
val prior = tools[key]
tools[key] = ToolUsage(
key, m.name.ifEmpty { dir ?: m.appId.toString() }, bytes + (prior?.bytes ?: 0), state, reason, count,
false, (prior?.manifests.orEmpty()) + m.file, (prior?.dirs.orEmpty()) + dirs,
)
continue
}
if (!m.installed) continue
val gameBytes = dir?.let { File(lib.root, "steamapps/common/$it") }?.takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val prefix = File(lib.data, "steamapps/compatdata/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val shaderDir = File(lib.data, "steamapps/shadercache/${m.appId}").takeIf { it.isDirectory }
val shaders = shaderDir?.let { walker.measure(it) } ?: 0
val workshop = File(lib.root, "steamapps/workshop/content/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val known = art[m.appId]
games.add(GameUsage(
m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed,
gameBytes, prefix, shaders, workshop, shaderDir,
))
}
}
File(steamRoot, "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir ->
if (!dir.isDirectory) return@forEach
val bytes = walker.measure(dir)
if (dir.name.startsWith(".") || dir.name.startsWith("droiddeck-") || dir.name.startsWith("bannerlator-") ||
!File(dir, "toolmanifest.vdf").isFile) return@forEach
val name = runCatching { SteamStorage.compatToolName(File(dir, "compatibilitytool.vdf").readText()) }.getOrNull() ?: dir.name
val count = gamesOn(name)
val isDefault = defaultTool == name
tools["dir:${dir.name}"] = ToolUsage(
"dir:${dir.name}", dir.name, bytes,
when {
mappings == null -> ToolState.REQUIRED
count > 0 || isDefault -> ToolState.IN_USE
else -> ToolState.UNUSED
},
if (mappings == null) ToolReason.CUSTOM_UNKNOWN else ToolReason.CUSTOM, count, isDefault, emptyList(), listOf(dir),
)
}
val leftovers = ArrayList<Leftover>()
for (lib in libs) {
if (!everyLibraryRead) break
val prefixes = File(lib.data, "steamapps/compatdata").listFiles().orEmpty()
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
val caches = File(lib.data, "steamapps/shadercache").listFiles().orEmpty()
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
for (id in (prefixes.keys + caches.keys).sorted()) {
val prefix = prefixes[id]
val cache = caches[id]
val bytes = (prefix?.let { walker.measure(it) } ?: 0) + (cache?.let { walker.measure(it) } ?: 0)
val safe = prefix == null || SteamStorage.isStubPrefix(prefix)
leftovers.add(Leftover(id, prefix, cache, bytes, safe))
}
}
val logs = SessionPaths.sessionFolders(context).filter { it != SessionPaths.current() } +
listOf(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR)).filter { it.exists() }
val logBytes = logs.sumOf { walker.measure(it) }
val flatpakDir = File(rootfs, "root/.local/share/flatpak")
val flatpakNames = runCatching { FlatpakManager.installedApps(context) }.getOrDefault(emptyList()).associate { it.id to it.name }
val flatpakApps = File(flatpakDir, "app").listFiles().orEmpty().filter { it.isDirectory }.map { dir ->
FlatpakUsage(dir.name, flatpakNames[dir.name] ?: dir.name.substringAfterLast('.'), walker.measure(dir))
}.sortedByDescending { it.bytes }
val flatpakRuntimes = File(flatpakDir, "runtime").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val componentsDir = File(files, "components")
val stored = runCatching { ComponentsManager.stored(context) }.onFailure { Log.w("StorageScan", "components", it) }.getOrNull()
val components = stored?.let { st ->
st.packages.map { ComponentUsage(it.pkg.comp, it.pkg.version, it.pkg.size, it.inUse, packageFile = it.pkg.file) } +
st.originals.map { ComponentUsage(it.comp, it.protonVersion, it.size, it.kept, protonId = it.protonId, protonName = it.protonName) }
}.orEmpty()
val otherPrefixes = libs.flatMap { lib ->
File(lib.data, "steamapps/compatdata").listFiles().orEmpty().filter { it.isDirectory && SteamStorage.leftoverId(it.name) == null }
}.sumOf { walker.measure(it) }
val apps = File(steamRoot, "steamapps")
val privateRoots = File(files, "steam-libraries").listFiles().orEmpty().filter { it.isDirectory }
fun m(file: File): Long = if (file.exists()) walker.measure(file) else 0
val toolDirs = tools.values.flatMap { it.dirs }.distinctBy { it.path }
val toolsInApps = toolDirs.filter { it.path.startsWith(apps.path + "/") }.sumOf { walker.known[it.path] ?: 0 }
val toolsPrivate = toolDirs.filter { d -> privateRoots.any { d.path.startsWith(it.path + "/") } }.sumOf { walker.known[it.path] ?: 0 }
val compatTools = m(File(steamRoot, "compatibilitytools.d"))
val installs = m(File(apps, "common")) + m(File(apps, "downloading")) + m(File(apps, "temp"))
val privatePrefixes = privateRoots.sumOf { m(File(it, "steamapps/compatdata")) }
val privateShaders = privateRoots.sumOf { m(File(it, "steamapps/shadercache")) }
val prefixes = m(File(apps, "compatdata")) + privatePrefixes
val shaders = m(File(apps, "shadercache")) + privateShaders
val workshop = m(File(apps, "workshop"))
val stores = m(File(rootfs, "root/Games"))
val flatpak = m(File(rootfs, "root/.local/share/flatpak"))
val rootfsBytes = m(rootfs)
val privateBytes = privateRoots.sumOf { m(it) }
val system = rootfsBytes - installs - m(File(apps, "compatdata")) - m(File(apps, "shadercache")) - workshop -
compatTools - stores - flatpak
val componentBytes = m(componentsDir)
val droiddeck = m(files) + m(context.cacheDir) - rootfsBytes - privateBytes - componentBytes +
(privateBytes - toolsPrivate - privatePrefixes - privateShaders)
val stat = StatFs(files.path)
return StorageReport(
System.currentTimeMillis(), stat.totalBytes, stat.availableBytes,
mapOf(
Bucket.GAMES to (installs - toolsInApps).coerceAtLeast(0),
Bucket.GAME_DATA to (prefixes + shaders + workshop),
Bucket.TOOLS to toolsInApps + toolsPrivate + compatTools,
Bucket.COMPONENTS to componentBytes,
Bucket.STORES to stores,
Bucket.DESKTOP to flatpak,
Bucket.SYSTEM to system.coerceAtLeast(0),
Bucket.DROIDDECK to droiddeck.coerceAtLeast(0),
),
prefixes, shaders, workshop, flatpak,
games.sortedByDescending { it.total }, tools.values.toList(), leftovers, logs, logBytes,
components, flatpakApps, flatpakRuntimes, otherPrefixes, everyLibraryRead,
)
}
}
@@ -0,0 +1,219 @@
package com.droiddeck.launcher.storage
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.StatFs
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.files.FileOps
import com.droiddeck.launcher.runtime.FlatpakManager
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.SessionState
enum class CleanItem { OLD_TOOLS, LEFTOVERS, LEFTOVERS_UNSURE, LOGS, UNUSED_CUSTOM, UNUSED_COMPONENTS, SHADERS }
object StorageState {
private const val TAG = "StorageState"
const val LOW_SPACE_BYTES = 10L * 1024 * 1024 * 1024
private const val FRESH_MS = 5L * 60 * 1000
var report by mutableStateOf<StorageReport?>(null)
private set
var scanning by mutableStateOf(false)
private set
var cleaning by mutableStateOf(false)
private set
var lastFreed by mutableStateOf<Long?>(null)
var progress by mutableStateOf<String?>(null)
private set
var lastError by mutableStateOf<String?>(null)
var openRequests by mutableIntStateOf(0)
private set
var openPending = false
@Volatile var lowSpaceDismissed = false
private val main = Handler(Looper.getMainLooper())
@Volatile private var appContext: Context? = null
fun requestOpen() {
openPending = true
openRequests++
}
fun refresh(context: Context, force: Boolean = false) {
val current = report
if (scanning || cleaning) return
if (!force && current != null && System.currentTimeMillis() - current.at < FRESH_MS) return
val app = context.applicationContext
scanning = true
Thread({
val result = runCatching { StorageScan.scan(app) }
.onFailure { Log.w(TAG, "storage scan failed", it) }.getOrNull()
main.post {
if (result != null) report = result
scanning = false
}
}, "storage-scan").start()
}
fun freeBytes(context: Context): Long = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrDefault(Long.MAX_VALUE)
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
CleanItem.LEFTOVERS_UNSURE -> report.leftovers.filter { !it.safe }.sumOf { it.bytes }
CleanItem.LOGS -> report.logBytes
CleanItem.UNUSED_CUSTOM -> report.unusedTools.filter { it.reason == ToolReason.CUSTOM }.sumOf { it.bytes }
CleanItem.UNUSED_COMPONENTS -> report.unusedComponents.sumOf { it.bytes }
CleanItem.SHADERS -> report.installedShaders
}
val SAFE = setOf(CleanItem.OLD_TOOLS, CleanItem.LEFTOVERS, CleanItem.LOGS)
fun safeBytes(report: StorageReport): Long = SAFE.sumOf { bytes(report, it) }
fun clean(context: Context, items: Set<CleanItem>, onDone: (Long?) -> Unit) {
if (cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
appContext = app
cleaning = true
Thread({
val before = freeBytes(app)
val fresh = runCatching { StorageScan.scan(app) }.onFailure { Log.w(TAG, "storage scan before cleanup failed", it) }.getOrNull()
if (fresh != null && !SessionState.running) {
for (item in items) runCatching { cleanItem(fresh, item) }.onFailure { Log.w(TAG, "could not clean $item", it) }
}
val freed = if (fresh == null) null else (freeBytes(app) - before).coerceAtLeast(0)
Log.i(TAG, "cleaned ${items.joinToString()} and freed $freed bytes")
finish(app, freed, onDone)
}, "storage-clean").start()
}
fun removeTool(context: Context, tool: ToolUsage, onDone: (Long?) -> Unit) {
if (tool.state != ToolState.UNUSED || cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
cleaning = true
Thread({
val before = freeBytes(app)
val current = runCatching { StorageScan.scan(app) }.getOrNull()?.tools?.firstOrNull { it.key == tool.key }
val freed = if (current?.state == ToolState.UNUSED && !SessionState.running) {
runCatching { deleteTool(current) }.onFailure { Log.w(TAG, "could not remove ${tool.key}", it) }
(freeBytes(app) - before).coerceAtLeast(0)
} else {
Log.w(TAG, "kept ${tool.key}: it is in use now or could not be checked")
null
}
finish(app, freed, onDone)
}, "storage-remove-tool").start()
}
// Every action works from a fresh scan so nothing is deleted on the strength of an older one.
private fun act(context: Context, name: String, action: (Context, StorageReport) -> Boolean, onDone: (Long?) -> Unit = {}) {
if (cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
appContext = app
cleaning = true
lastError = null
Thread({
val before = freeBytes(app)
val fresh = runCatching { StorageScan.scan(app) }.getOrNull()
val done = fresh != null && !SessionState.running &&
runCatching { action(app, fresh) }.onFailure { Log.w(TAG, "$name failed", it); main.post { lastError = it.message } }.getOrDefault(false)
val freed = if (done) (freeBytes(app) - before).coerceAtLeast(0) else null
Log.i(TAG, "$name: freed $freed bytes")
main.post { progress = null }
finish(app, freed, onDone)
}, "storage-$name").start()
}
fun clearShaders(context: Context, appId: Int) = act(context, "shaders-$appId", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == appId }?.shaderDir ?: return@act false
FileOps.delete(dir)
})
fun removeLeftover(context: Context, appId: Int) = act(context, "leftover-$appId", { _, fresh ->
val leftover = fresh.leftovers.firstOrNull { it.appId == appId } ?: return@act false
deleteLeftover(leftover)
true
})
fun removeComponent(context: Context, usage: ComponentUsage) = act(context, "component", { app, fresh ->
val current = fresh.components.firstOrNull { it.packageFile == usage.packageFile && it.protonId == usage.protonId &&
it.comp == usage.comp && it.version == usage.version } ?: return@act false
!current.inUse && deleteComponent(app, current)
})
fun uninstallFlatpak(context: Context, id: String) = act(context, "flatpak-$id", { app, fresh ->
if (fresh.flatpakApps.none { it.id == id }) return@act false
val error = FlatpakManager.uninstall(app, id) { step, _ -> main.post { progress = step } }
if (error != null) main.post { lastError = error }
error == null
})
fun pruneFlatpak(context: Context) = act(context, "flatpak-prune", { app, _ ->
val error = FlatpakManager.prune(app) { step, _ -> main.post { progress = step } }
if (error != null) main.post { lastError = error }
error == null
})
private fun deleteComponent(app: Context, usage: ComponentUsage): Boolean {
if (usage.inUse) return false
usage.packageFile?.let { file ->
return runCatching { ComponentsManager.deletePackage(app, file) }.onFailure { Log.w(TAG, "kept $file", it) }.isSuccess
}
val proton = usage.protonId ?: return false
return ComponentsManager.deleteUnusedOriginal(app, proton, usage.comp, usage.version)
}
private fun finish(app: Context, freed: Long?, onDone: (Long?) -> Unit) {
val rescanned = runCatching { StorageScan.scan(app) }.getOrNull()
main.post {
if (rescanned != null) report = rescanned
cleaning = false
lastFreed = freed
onDone(freed)
}
}
private fun cleanItem(report: StorageReport, item: CleanItem) {
when (item) {
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.forEach(::deleteTool)
CleanItem.UNUSED_CUSTOM -> report.unusedTools.filter { it.reason == ToolReason.CUSTOM }.forEach(::deleteTool)
CleanItem.UNUSED_COMPONENTS -> report.unusedComponents.forEach { deleteComponent(appContext!!, it) }
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.forEach(::deleteLeftover)
CleanItem.LEFTOVERS_UNSURE -> report.leftovers.filter { !it.safe }.forEach(::deleteLeftover)
CleanItem.LOGS -> report.logs.forEach { FileOps.delete(it) }
CleanItem.SHADERS -> report.games.mapNotNull { it.shaderDir }.forEach { FileOps.delete(it) }
}
}
// Steam treats an app as installed while its manifest exists, so the manifest goes first.
private fun deleteTool(tool: ToolUsage) {
tool.manifests.forEach { check(!it.exists() || it.delete()) { "cannot delete $it" } }
tool.dirs.forEach { FileOps.delete(it) }
}
private fun deleteLeftover(leftover: Leftover) {
leftover.prefix?.let { FileOps.delete(it) }
leftover.shaders?.let { FileOps.delete(it) }
}
fun format(bytes: Long): String {
val gb = bytes / (1024.0 * 1024 * 1024)
return when {
gb >= 100 -> String.format(java.util.Locale.US, "%.0f GB", gb)
gb >= 10 -> String.format(java.util.Locale.US, "%.1f GB", gb)
gb >= 0.995 -> String.format(java.util.Locale.US, "%.2f GB", gb)
bytes >= 1024L * 1024 -> String.format(java.util.Locale.US, "%.0f MB", bytes / (1024.0 * 1024))
else -> "0 MB"
}
}
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.epic.EpicAchievements
@@ -15,6 +16,7 @@ import java.util.Locale
abstract class StoreAchievements(val source: String, val label: String) {
@Immutable
data class Game(val source: String, val id: String, val extra: Map<String, String> = emptyMap()) {
companion object {
fun of(item: CatalogItem) = Game(item.store.id, item.id, item.extra)
@@ -31,6 +31,7 @@ class StoreShelves(
val deals: List<CatalogItem> = emptyList(),
val free: List<CatalogItem> = emptyList(),
val trending: List<CatalogItem> = emptyList(),
val art: List<String> = emptyList(),
) {
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
@@ -52,6 +53,35 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: L
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
}
private fun releaseDateValue(item: CatalogItem): Long =
item.releaseDate.take(10).takeIf { it.length == 10 }?.let { raw ->
runCatching { java.time.LocalDate.parse(raw).toEpochDay() }.getOrDefault(0L)
} ?: 0L
fun sortCatalogItems(
items: List<CatalogItem>,
sort: String,
lastPlayed: (CatalogItem) -> Long = { 0L },
installedAt: (CatalogItem) -> Long = { 0L },
size: (CatalogItem) -> Long = { it.sizeBytes },
): List<CatalogItem> = when (sort) {
"lastPlayed" -> items.sortedWith(compareByDescending(lastPlayed).thenBy { it.title.lowercase() })
"recentlyInstalled" -> items.sortedWith(compareByDescending(installedAt).thenBy { it.title.lowercase() })
"titleAsc" -> items.sortedBy { it.title.lowercase() }
"titleDesc" -> items.sortedByDescending { it.title.lowercase() }
"sizeDesc" -> items.sortedWith(compareByDescending(size).thenBy { it.title.lowercase() })
"sizeAsc" -> items.sortedWith(compareBy<CatalogItem> { size(it).takeIf { b -> b > 0 } ?: Long.MAX_VALUE }.thenBy { it.title.lowercase() })
"releaseDateDesc" -> items.sortedWith(compareByDescending(::releaseDateValue).thenBy { it.title.lowercase() })
else -> items
}
internal fun retainCatalogOrder(items: List<CatalogItem>, previous: List<CatalogItem>): List<CatalogItem> {
if (items.size != previous.size) return items
val byKey = items.associateBy { it.key }
if (previous.any { it.key !in byKey }) return items
return previous.map { byKey.getValue(it.key) }
}
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
if (old.isNullOrEmpty()) return new
val before = old.associateBy { it.key }
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.stores
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.net.http.SslError
@@ -64,6 +65,10 @@ class StoreLoginActivity : ComponentActivity() {
private lateinit var errorPanel: LinearLayout
private lateinit var errorText: TextView
private lateinit var titleText: TextView
private lateinit var topBar: LinearLayout
private lateinit var reloadButton: ImageButton
private lateinit var closeButton: ImageButton
private lateinit var retryButton: Button
private var oauthState: String? = null
private val captured = AtomicBoolean(false)
private var flooded = false
@@ -130,6 +135,7 @@ class StoreLoginActivity : ComponentActivity() {
contentDescription = getString(R.string.stores_login_close); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { finish() }
}
topBar = bar; reloadButton = reload; closeButton = close
bar.addView(titleText); bar.addView(reload); bar.addView(close)
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = 100; isIndeterminate = false
@@ -144,6 +150,7 @@ class StoreLoginActivity : ComponentActivity() {
}
errorText = TextView(this).apply { setTextColor(0xFFF2F4F7.toInt()); textSize = 15f; gravity = Gravity.CENTER }
val retry = Button(this).apply { text = getString(R.string.stores_login_retry); setOnClickListener { reloadLogin() } }
retryButton = retry
errorPanel.addView(errorText)
errorPanel.addView(retry, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) })
contentHost.addView(errorPanel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
@@ -151,6 +158,24 @@ class StoreLoginActivity : ComponentActivity() {
return root
}
/**
* The activity handles density changes itself so that folding between two displays of different
* density keeps the page and the sign-in in progress; the dp-based chrome sizes are applied again.
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (!::topBar.isInitialized) return
topBar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))
titleText.setPadding(dp(16), 0, dp(8), 0)
titleText.textSize = 16f
errorText.textSize = 15f
reloadButton.layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
closeButton.layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
progressBar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3))
errorPanel.setPadding(dp(24), dp(24), dp(24), dp(24))
retryButton.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) }
}
private fun showError(message: String) {
runOnUiThread { errorText.text = message; errorPanel.visibility = View.VISIBLE; errorPanel.bringToFront(); progressBar.visibility = View.GONE }
}
@@ -171,6 +196,9 @@ class StoreLoginActivity : ComponentActivity() {
setBackgroundColor(0xFF0A0B0D.toInt())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
// Store sign-in pages are web pages; they never need to read local files or content:// URIs.
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.userAgentString = userAgent
settings.setSupportMultipleWindows(true)
settings.javaScriptCanOpenWindowsAutomatically = true
@@ -12,6 +12,7 @@ import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
import com.droiddeck.launcher.stores.epic.EpicInstallTags
import com.droiddeck.launcher.stores.epic.EpicPrefs
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
@@ -20,7 +21,8 @@ object StoreSizes {
val known = mutableStateMapOf<String, Long>()
private val asked = ConcurrentHashMap.newKeySet<String>()
private val sizeRequests = ConcurrentHashMap<String, CompletableFuture<Long?>>()
private val diskRequests = ConcurrentHashMap<String, CompletableFuture<Long?>>()
private val pool = Executors.newFixedThreadPool(3, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("sizes"))
fun size(item: CatalogItem): Long = known[item.key] ?: item.sizeBytes
@@ -29,22 +31,35 @@ object StoreSizes {
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
fun request(context: Context, item: CatalogItem) {
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG) return
if (known.containsKey(item.key) || !asked.add(item.key)) return
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
resolve(context, item).thenAccept { bytes ->
if (bytes != null) StoresState.post { known[item.key] = bytes }
}
}
fun resolve(context: Context, item: CatalogItem): CompletableFuture<Long?> {
val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 }
if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
if (available != null) return CompletableFuture.completedFuture(available)
val app = context.applicationContext
val version = item.extra["version"].orEmpty()
pool.execute {
val cached = cache(app, item.store).get(item.id, version)
if (cached != null) { StoresState.post { known[item.key] = cached }; return@execute }
val bytes = runCatching { fetch(app, item) }.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }.getOrNull() ?: 0L
if (bytes > 0) {
cache(app, item.store).put(item.id, version, bytes)
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.GOG -> {}
return sizeRequests.computeIfAbsent(item.key) {
CompletableFuture.supplyAsync({
val cached = cache(app, item.store).get(item.id, version)
val bytes = cached ?: runCatching { fetch(app, item) }
.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }
.getOrNull()
bytes?.takeIf { it > 0 }?.also {
cache(app, item.store).put(item.id, version, it)
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.GOG -> {}
}
}
StoresState.post { known[item.key] = bytes }
}, pool).exceptionally { error ->
Log.w(TAG, "${item.key}: size resolution failed", error)
null
}
}
}
@@ -52,14 +67,25 @@ object StoreSizes {
val onDisk = mutableStateMapOf<String, Long>()
fun requestDisk(context: Context, key: String, game: InstalledStoreGame) {
if (onDisk.containsKey(key) || !asked.add("disk:$key")) return
resolveDisk(context, key, game).thenAccept { bytes ->
if (bytes != null) StoresState.post { onDisk[key] = bytes }
}
}
fun resolveDisk(context: Context, key: String, game: InstalledStoreGame): CompletableFuture<Long?> {
onDisk[key]?.let { return CompletableFuture.completedFuture(it) }
val app = context.applicationContext
val version = "disk:${game.sidecar.installedAt}:${game.sidecar.installVersion}"
pool.execute {
val c = cache(app, game.sidecar.store)
val bytes = c.get("disk:${game.sidecar.id}", version)
?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) }
if (bytes > 0) StoresState.post { onDisk[key] = bytes }
return diskRequests.computeIfAbsent(key) {
CompletableFuture.supplyAsync({
val c = cache(app, game.sidecar.store)
c.get("disk:${game.sidecar.id}", version)
?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }
.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) }
}, pool).thenApply { it.takeIf { bytes -> bytes > 0 } }.exceptionally { error ->
Log.w(TAG, "$key: disk size resolution failed", error)
null
}
}
}
@@ -71,7 +71,7 @@ object StoresState {
fun refresh(context: Context) {
val app = context.applicationContext
com.droiddeck.launcher.stores.download.SavedQueue.restore(app)
Thread({
background("stores-refresh") {
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) }
@@ -85,7 +85,7 @@ object StoresState {
unfinished = partial
engine = version
}
}, "stores-refresh").start()
}
}
fun isSignedIn(store: Store): Boolean = accounts.containsKey(store)
@@ -136,7 +136,7 @@ object StoresState {
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
Thread({
background("stores-signout") {
backends[store]?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else StoreAccounts.clear(app, store)
@@ -144,7 +144,7 @@ object StoresState {
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
}, "stores-signout").start()
}
}
var openDownloads by mutableStateOf(false)
@@ -199,7 +199,7 @@ object StoresState {
val app = context.applicationContext
val key = "${game.sidecar.store.id}:${game.sidecar.id}"
uninstalling[key] = 0f
Thread({
background("stores-uninstall", onFailure = { main.post { uninstalling.remove(key); onDone() } }) {
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
StoreInstalls.uninstall(app, game, progress = { f -> main.post { if (key in uninstalling) uninstalling[key] = f } }, removed = {
main.post {
@@ -208,7 +208,7 @@ object StoresState {
}
})
main.post(onDone)
}, "stores-uninstall").start()
}
}
@Volatile var libraryListener: (() -> Unit)? = null
@@ -231,13 +231,31 @@ object StoresState {
appContext = context.applicationContext
StoreLogFiles.prune(context)
val overlay = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/droiddeck/epic-overlay")
if (overlay.exists()) Thread({ overlay.deleteRecursively(); Log.i(TAG, "removed the old EOS overlay download") }, "epic-overlay-cleanup").start()
if (overlay.exists()) background("epic-overlay-cleanup") { overlay.deleteRecursively(); Log.i(TAG, "removed the old EOS overlay download") }
val app = context.applicationContext
Thread({ CloudSaves.uploadAllDirty(app, "recovery") }, "cloud-recovery").start()
Thread({
runCatching { CloudSaves.uploadAllDirty(app, "recovery") }
.onFailure { Log.w(TAG, "cloud save recovery failed", it) }
}, "cloud-recovery").start()
}
internal fun post(block: () -> Unit) = main.post(block)
/**
* A worker thread whose failure is logged. An uncaught exception on a plain Thread reaches the crash
* handler, which ends the whole app, and none of these jobs is worth that.
*/
private fun background(name: String, onFailure: () -> Unit = {}, block: () -> Unit) {
Thread({
try {
block()
} catch (e: Throwable) {
Log.w(TAG, "$name failed", e)
runCatching(onFailure)
}
}, name).start()
}
private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
}
@@ -55,7 +55,20 @@ object AmazonBackend : StoreBackend {
Thread({ AmazonLibrary.sync(app, force) }, "amazon-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
AmazonShelves.cached(app)?.let { s -> StoresState.post { withArt(s.art) } }
Thread({
val shelves = AmazonShelves.featured(app, force)
if (shelves != null) StoresState.post { withArt(shelves.art) }
}, "amazon-shelves").start()
}
private fun withArt(art: List<String>) {
val s = StoresState.shelves[store]
StoresState.shelves[store] = if (s == null) com.droiddeck.launcher.stores.StoreShelves(art = art)
else com.droiddeck.launcher.stores.StoreShelves(s.whatsNew, s.deals, s.free, s.trending, art)
}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
@@ -102,7 +102,7 @@ object AmazonLibrary {
StoresState.post {
StoresState.library[store] = mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed })
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
}
}
@@ -0,0 +1,92 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
object AmazonShelves {
private const val TAG = "AmazonShelves"
private const val PAGE = "https://luna.amazon.com/claims/home"
private const val GRAPHQL = "https://luna.amazon.com/graphql"
private const val KEY_ART = "store_art"
private const val KEY_ART_AT = "store_art_at"
private const val FRESH_MS = 3 * 60 * 60 * 1000L
private const val QUERY = "query { items(collectionType: FEATURED_CONTENT, pageSize: 50) { items { assets { title " +
"cardMedia { defaultMedia { src1x src2x } } } } } }"
@Volatile private var memory: StoreShelves? = null
fun cached(context: Context): StoreShelves? =
memory ?: fromPrefs(AmazonPrefs.get(context).getString(KEY_ART, null))?.also { memory = it }
fun featured(context: Context, force: Boolean): StoreShelves? {
val prefs = AmazonPrefs.get(context)
if (!force && System.currentTimeMillis() - prefs.getLong(KEY_ART_AT, 0L) in 0 until FRESH_MS) cached(context)?.let { return it }
val art = runCatching { fetch() }.onFailure { Log.w(TAG, "featured: ${it.javaClass.simpleName}") }.getOrNull().orEmpty()
if (art.isEmpty()) return cached(context)
prefs.edit().putString(KEY_ART, JSONArray(art).toString()).putLong(KEY_ART_AT, System.currentTimeMillis()).apply()
Log.i(TAG, "featured art: ${art.size}")
return StoreShelves(art = art).also { memory = it }
}
private fun fromPrefs(text: String?): StoreShelves? {
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
val art = List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() }
return if (art.isEmpty()) null else StoreShelves(art = art)
}
private fun fetch(): List<String> {
val (page, cookies) = openPage() ?: return emptyList()
val token = Regex("""name=['"]csrf-key['"] value=['"]([^'"]+)['"]""").find(page)?.groupValues?.get(1) ?: run {
Log.w(TAG, "claims page had no csrf key")
return emptyList()
}
val body = StoreNet.postJson(
GRAPHQL, JSONObject().put("query", QUERY).toString(),
headers = mapOf("client-id" to "CarboniteApp", "csrf-token" to token, "Cookie" to cookies, "Origin" to "https://luna.amazon.com", "Referer" to PAGE),
) ?: return emptyList()
val items = JSONObject(body).optJSONObject("data")?.optJSONObject("items")?.optJSONArray("items") ?: return emptyList()
return (0 until items.length()).mapNotNull { i ->
val media = items.optJSONObject(i)?.optJSONObject("assets")?.optJSONObject("cardMedia")?.optJSONObject("defaultMedia")
media?.optString("src2x")?.takeIf { it.isNotEmpty() } ?: media?.optString("src1x")?.takeIf { it.isNotEmpty() }
}.distinct()
}
private fun openPage(): Pair<String, String>? {
val jar = LinkedHashMap<String, String>()
var url = PAGE
repeat(5) {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20_000
readTimeout = 20_000
instanceFollowRedirects = false
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
setRequestProperty("Accept", "text/html")
if (jar.isNotEmpty()) setRequestProperty("Cookie", jar.entries.joinToString("; ") { "${it.key}=${it.value}" })
}
try {
val code = conn.responseCode
conn.headerFields["Set-Cookie"].orEmpty().forEach { c ->
val pair = c.substringBefore(';')
val name = pair.substringBefore('=').trim()
if (name.isNotEmpty() && '=' in pair) jar[name] = pair.substringAfter('=')
}
if (code in 300..399) {
url = URL(URL(url), conn.getHeaderField("Location") ?: return null).toString()
return@repeat
}
if (code !in 200..299) { Log.w(TAG, "claims page $code"); return null }
val page = conn.inputStream.bufferedReader().use { it.readText() }
return page to jar.entries.joinToString("; ") { "${it.key}=${it.value}" }
} finally {
runCatching { conn.disconnect() }
}
}
return null
}
}
@@ -64,14 +64,16 @@ object EpicShelves {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull(::itemFrom).distinctBy { it.id }
}
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"))
return if (s.isEmpty) null else s
val art = o.optJSONArray("art")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } }.orEmpty()
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"), art)
return if (s.isEmpty && art.isEmpty()) null else s
}
fun toJson(s: StoreShelves): String = JSONObject().apply {
listOf(s.whatsNew, s.deals, s.free, s.trending).zip(SHELF_KEYS).forEach { (items, key) ->
put(key, JSONArray().apply { items.forEach { put(itemJson(it)) } })
}
put("art", JSONArray(s.art))
}.toString()
fun cached(context: Context): StoreShelves? =
@@ -111,6 +113,6 @@ object EpicShelves {
releaseDate = owned.releaseDate.ifBlank { shelf.releaseDate },
)
}
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
}
}
@@ -62,7 +62,7 @@ object GogBackend : StoreBackend {
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
}
override fun install(context: Context, item: CatalogItem, root: File) {
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -40,6 +41,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -56,6 +58,8 @@ import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AddedGameArt
import com.droiddeck.launcher.frontend.AddedGameEdits
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.SteamGameArt
import com.droiddeck.launcher.frontend.SteamGridDb
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -64,6 +68,7 @@ import java.io.File
private fun AddedGameArt.Slot.shape(): Triple<Dp, Dp, Dp> = when (this) {
AddedGameArt.Slot.COVER -> Triple(28.dp, 41.dp, 5.dp)
AddedGameArt.Slot.WIDE -> Triple(60.dp, 28.dp, 5.dp)
AddedGameArt.Slot.BACKGROUND -> Triple(60.dp, 34.dp, 5.dp)
AddedGameArt.Slot.LOGO -> Triple(60.dp, 26.dp, 5.dp)
AddedGameArt.Slot.ICON -> Triple(30.dp, 30.dp, 7.dp)
@@ -73,6 +78,7 @@ private fun AddedGameArt.Slot.shape(): Triple<Dp, Dp, Dp> = when (this) {
private fun AddedGameArt.Slot.label(): String = stringResource(
when (this) {
AddedGameArt.Slot.COVER -> R.string.art_cover
AddedGameArt.Slot.WIDE -> R.string.art_wide_cover
AddedGameArt.Slot.BACKGROUND -> R.string.art_background
AddedGameArt.Slot.LOGO -> R.string.art_logo
AddedGameArt.Slot.ICON -> R.string.art_icon
@@ -140,6 +146,90 @@ internal class EditPreview(
}
}
internal class ArtTarget(
val name: String,
val chosenRef: (Context, AddedGameArt.Slot) -> String,
val choices: (Context, AddedGameArt.Slot) -> AddedGameArt.Choices,
val set: (Context, AddedGameArt.Slot, AddedGameArt.Option) -> Boolean,
val reset: (Context, AddedGameArt.Slot) -> Unit,
) {
companion object {
fun of(game: AddedGames.Game) = ArtTarget(
game.name,
{ c, slot -> AddedGameArt.chosenSource(c, game, slot).second },
{ c, slot -> AddedGameArt.choices(c, game, slot) },
{ c, slot, option -> AddedGameEdits.setArt(c, game.folder.path, slot, option) },
{ c, slot -> AddedGameEdits.resetArt(c, game.folder.path, slot) },
)
fun of(game: com.droiddeck.launcher.frontend.Library.SteamGame) = ArtTarget(
game.name,
{ c, slot -> SteamGameArt.chosenSource(c, game.appId, slot).second },
{ c, slot -> SteamGameArt.choices(c, game, slot) },
{ c, slot, option -> SteamGameArt.choose(c, game, slot, option) },
{ c, slot -> SteamGameArt.reset(c, game, slot) },
)
}
}
@Composable
internal fun SteamGameArtEditor(
game: com.droiddeck.launcher.frontend.Library.SteamGame, onClose: () -> Unit, onChanged: () -> Unit,
) {
val target = remember(game.appId) { ArtTarget.of(game) }
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged)
}
@Composable
internal fun StoreGameArtEditor(folder: String, name: String, onClose: () -> Unit, onChanged: () -> Unit) {
val app = LocalContext.current.applicationContext
var game by remember(folder) { mutableStateOf<AddedGames.Game?>(null) }
LaunchedEffect(folder) { game = withContext(Dispatchers.IO) { AddedGameEdits.game(app, folder) } }
val g = game
val target = remember(g?.appId) { g?.let { ArtTarget.of(it) } }
ArtOnlyEditor(name, target, g, { c -> g?.let { AddedGameArt.describe(c, it) }.orEmpty() }, onClose, onChanged)
}
@Composable
private fun ArtOnlyEditor(
name: String, target: ArtTarget?, refresh: Any?, describe: (Context) -> List<AddedGameArt.SlotState>,
onClose: () -> Unit, onChanged: () -> Unit,
) {
val app = LocalContext.current.applicationContext
val colors = MaterialTheme.colorScheme
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
var version by remember { mutableIntStateOf(0) }
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
LaunchedEffect(version, refresh) { if (target != null) arts = withContext(Dispatchers.IO) { describe(app) } }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
AppDialog(shown, close, "artEditor", wide = false, maxWidth = 460.dp) {
DialogTitleRow(name, onClose = close)
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
for (slot in AddedGameArt.Slot.values()) key(slot) {
val state = arts.firstOrNull { it.slot == slot }
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SlotThumb(slot, state?.file)
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
}
}
}
}
}
val chosen = target ?: return
artSlot?.let { slot ->
ArtChooserDialog(chosen, slot, arts.firstOrNull { it.slot == slot }, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
}
}
@Composable
private fun Loading() {
androidx.compose.material3.CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
@@ -281,7 +371,7 @@ internal fun AddedGameEditor(
})
artSlot?.let { slot ->
val current = arts.firstOrNull { it.slot == slot }
ArtChooserDialog(g, slot, current, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
ArtChooserDialog(ArtTarget.of(g), slot, current, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
}
}
@@ -346,7 +436,7 @@ internal fun TextEntryDialog(title: String, initial: String, onDismiss: () -> Un
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ArtChooserDialog(
game: AddedGames.Game, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
target: ArtTarget, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
onBack: () -> Unit, onCloseAll: () -> Unit, onChanged: () -> Unit,
) {
val ctx = LocalContext.current
@@ -356,16 +446,24 @@ private fun ArtChooserDialog(
val shown = rememberShown(onBack)
val back = { shown.targetState = false }
var options by remember { mutableStateOf<List<AddedGameArt.Option>?>(null) }
var sgdb by remember { mutableStateOf(SteamGridDb.Status.OK) }
var picking by remember { mutableStateOf(false) }
val chosenRef = remember(current) { AddedGameArt.chosenSource(app, game, slot).second }
LaunchedEffect(slot) { options = withContext(Dispatchers.IO) { runCatching { AddedGameArt.options(app, game, slot) }.getOrDefault(emptyList()) } }
var searching by remember { mutableStateOf(false) }
val chosenRef = remember(current) { target.chosenRef(app, slot) }
LaunchedEffect(slot) {
val found = withContext(Dispatchers.IO) { runCatching { target.choices(app, slot) }.getOrNull() }
options = found?.options ?: emptyList()
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
val apply: (AddedGameArt.Option) -> Unit = { option ->
scope.launch {
withContext(Dispatchers.IO) { AddedGameEdits.setArt(app, game.folder.path, slot, option) }
onChanged()
back()
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
if (saved) {
onChanged()
back()
}
}
}
val slotName = slot.label()
@@ -394,11 +492,13 @@ private fun ArtChooserDialog(
}
}
}
if (sgdb != SteamGridDb.Status.OK) SgdbNote(sgdb)
}
PickerRow(Icons.Outlined.Search, stringResource(R.string.art_search_sgdb), null) { searching = true }
PickerRow(Icons.Outlined.Image, stringResource(R.string.art_pick_image), null, focus = if (opts?.isEmpty() != false) first else null) { picking = true }
PickerRow(null, stringResource(R.string.art_reset), null) {
scope.launch {
withContext(Dispatchers.IO) { AddedGameEdits.resetArt(app, game.folder.path, slot) }
withContext(Dispatchers.IO) { target.reset(app, slot) }
onChanged()
back()
}
@@ -408,6 +508,117 @@ private fun ArtChooserDialog(
if (picking) ExePickerDialog(stringResource(R.string.art_pick_title, slotName), kind = PickKind.IMAGE, onDismiss = { picking = false }, onPick = { file ->
apply(AddedGameArt.Option(AddedGameArt.FILE, file.path, file.path))
})
if (searching) SgdbSearchDialog(
target, slot, current,
onBack = { searching = false }, onCloseAll = { searching = false; onCloseAll() }, onPick = apply,
)
}
@Composable
private fun SgdbNote(status: SteamGridDb.Status) {
val colors = MaterialTheme.colorScheme
val quiet = status == SteamGridDb.Status.NO_KEY || status == SteamGridDb.Status.NO_GAME
Text(
stringResource(
when (status) {
SteamGridDb.Status.NO_KEY -> R.string.art_sgdb_no_key
SteamGridDb.Status.BAD_KEY -> R.string.art_sgdb_bad_key
SteamGridDb.Status.LIMIT -> R.string.art_sgdb_limit
SteamGridDb.Status.OFFLINE -> R.string.art_sgdb_offline
SteamGridDb.Status.NO_GAME -> R.string.art_sgdb_no_game
else -> R.string.art_sgdb_server
},
),
fontSize = 12.sp, color = if (quiet) colors.onSurfaceVariant else colors.error, modifier = Modifier.padding(start = 2.dp, top = 4.dp),
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SgdbSearchDialog(
target: ArtTarget, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
onBack: () -> Unit, onCloseAll: () -> Unit, onPick: (AddedGameArt.Option) -> Unit,
) {
val app = LocalContext.current.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
val shown = rememberShown(onBack)
val back = { shown.targetState = false }
var query by remember { mutableStateOf(target.name) }
var matches by remember { mutableStateOf<List<SteamGridDb.Game>?>(null) }
var picked by remember { mutableStateOf<SteamGridDb.Game?>(null) }
var images by remember { mutableStateOf<List<AddedGameArt.Option>>(emptyList()) }
var busy by remember { mutableStateOf(true) }
var status by remember { mutableStateOf(SteamGridDb.Status.OK) }
val chosenRef = remember(current) { target.chosenRef(app, slot) }
val slotName = slot.label()
val showImages: (SteamGridDb.Game) -> Unit = { found ->
scope.launch {
picked = found
images = emptyList()
busy = true
status = SteamGridDb.Status.OK
val result = withContext(Dispatchers.IO) { AddedGameArt.gameImages(app, found.id, slot) }
images = result.items
status = result.status
busy = false
}
}
val search: () -> Unit = {
scope.launch {
busy = true
picked = null
status = SteamGridDb.Status.OK
val result = withContext(Dispatchers.IO) { AddedGameArt.searchGames(app, query) }
matches = result.items
status = result.status
busy = false
val sure = result.items.firstOrNull { AddedGameArt.nameMatch(query, it.name) == 3 } ?: result.items.singleOrNull()
if (sure != null) showImages(sure)
}
}
LaunchedEffect(Unit) { search() }
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = {
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
})
androidx.compose.material3.OutlinedTextField(
query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.art_sgdb_search_label)) },
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
)
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
if (status != SteamGridDb.Status.OK) SgdbNote(status)
val chosenGame = picked
val list = matches.orEmpty()
if (chosenGame == null) {
for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } }
if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
} else {
Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (option in images) key(option.full) {
ArtOption(
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null,
description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName",
) { onPick(option) }
}
}
if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
}
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
}
}
}
@Composable
@@ -56,6 +56,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
@@ -101,7 +102,7 @@ const val GPU_TAB = "gpu"
const val PROTON_TAB = "proton"
internal val LAYERS = listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB)
internal val LAYERS = listOf(GPU_TAB, PROTON_TAB, "dxvk", "vkd3d", "fex")
internal fun layerStep(current: String, by: Int): String {
val i = LAYERS.indexOf(current).coerceAtLeast(0)
@@ -127,6 +128,7 @@ private val QUEUED = Color(0xFFFFB86B)
private val ROW_SHAPE = RoundedCornerShape(10.dp)
private val LAYER_SHAPE = RoundedCornerShape(12.dp)
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun ComponentsPage(
snapshot: Snapshot?,
@@ -417,12 +419,14 @@ fun ComponentsPage(
var driverShown by remember { mutableStateOf<String?>(null) }
var driverLeaving by remember { mutableStateOf(false) }
var driverFrom by remember { mutableStateOf<Origin?>(null) }
val slotFocus = remember { mapOf(SLOT_DISPLAY to FocusRequester(), SLOT_RUNTIME to FocusRequester()) }
LaunchedEffect(driverPage) {
val d = driverPage
if (d != null) { driverLeaving = false; driverShown = d }
else if (driverShown != null) {
else driverShown?.let { closed ->
if (driverFrom != null) { driverLeaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) }
driverShown = null; driverLeaving = false; driverFrom = null
if (inputMode.inputMode == InputMode.Keyboard) focusSoon { slotFocus.getValue(closed) }
}
}
var narrowOpen by rememberSaveable { mutableStateOf<String?>(null) }
@@ -467,16 +471,6 @@ fun ComponentsPage(
val compState = { l: String -> view?.components?.get(l) }
val bar: @Composable (String) -> Unit = { l ->
when (l) {
GPU_TAB -> {
val active = gpu.activePair
InUseBar(
queued = gpu.busy != null,
sentence = (active?.let { "${it.name} ${it.version}" } ?: gpu.activeBundle ?: stringResource(R.string.gpu_builtin))
.let { if (gpu.auto) stringResource(R.string.gpu_bar_auto, it) else it },
note = if (gpu.busy != null) (if (gpu.percent >= 0) "${gpu.percent}%" else "…")
else gpu.autoStatus.ifEmpty { if (active != null || gpu.activeBundle != null) "" else gpu.pairs.firstOrNull { it.recommended }?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint) },
)
}
PROTON_TAB -> InUseBar(
queued = false,
sentence = view?.let { stringResource(R.string.comp_proton_bar, it.proton.name) } ?: stringResource(R.string.comp_no_proton),
@@ -500,51 +494,20 @@ fun ComponentsPage(
}
}
var showAllPairs by rememberSaveable { mutableStateOf(false) }
val installedTitle: @Composable (String) -> String = { l ->
when (l) {
GPU_TAB -> stringResource(R.string.comp_installed_count, 1 + gpu.pairs.count { it.installed && (it.suits || showAllPairs) })
PROTON_TAB -> stringResource(R.string.comp_installed_count, views.size)
else -> stringResource(R.string.comp_installed_count, installedFor(l, view, snapshot).size)
}
}
val availableTitle: @Composable (String) -> String = { l ->
when (l) {
GPU_TAB -> stringResource(R.string.comp_available_count, gpu.pairs.count { !it.installed && it.complete && (it.suits || showAllPairs) })
PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null })
else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size)
}
}
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
when (l) {
GPU_TAB -> {
val autoKey = "gpu:auto"
ChoiceLine(
stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_keeps), gpu.auto, removable = false,
modifier = Modifier.focusRequester(firstRow).trackRow(autoKey, rowsAt),
onSelect = {
if (gpu.auto) return@ChoiceLine
ask(autoKey, right, ctx.getString(R.string.gpu_use_title, ctx.getString(R.string.common_auto)), ctx.getString(R.string.gpu_auto_hint), ctx.getString(R.string.common_use), false, ctx.getString(R.string.common_auto)) {
gpuActions.onAuto(true); flyHome(autoKey, GPU_TAB)
}
},
)
for (p in gpu.pairs.filter { it.installed && (it.suits || showAllPairs) }) {
val key = "gpu:${p.key}"
ChoiceLine(
p.name, "${p.version} · ${p.detail}", !gpu.auto && p.active, removable = false,
badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null,
progress = if (gpu.busy == p.key) gpu.percent else null,
modifier = Modifier.trackRow(key, rowsAt),
onSelect = {
if (!gpu.auto && p.active || gpu.busy != null) return@ChoiceLine
ask(key, right, ctx.getString(R.string.gpu_use_title, p.name), "${p.version} · ${p.detail}", ctx.getString(R.string.common_use), false, p.name) {
gpuActions.onAuto(false); gpuActions.onPair(p.key); flyHome(key, GPU_TAB)
}
},
)
}
}
PROTON_TAB -> {
if (views.isEmpty()) EmptyLine(stringResource(if (snapshot == null) R.string.comp_reading else R.string.comp_no_proton))
views.forEachIndexed { i, v ->
@@ -599,17 +562,6 @@ fun ComponentsPage(
}
val availableRows: @Composable ColumnScope.(String) -> Unit = { l ->
when (l) {
GPU_TAB -> {
val shown = gpu.pairs.filter { !it.installed && it.complete && (it.suits || showAllPairs) }
if (shown.isEmpty()) EmptyLine(stringResource(if (gpu.checking) R.string.gpu_checking else R.string.gpu_none_listed))
for (p in shown) GetLine(
p.name, "${p.version} · ${p.detail}", progress = if (gpu.busy == p.key) gpu.percent else null, enabled = gpu.busy == null,
badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null,
) { gpuActions.onAuto(false); gpuActions.onPair(p.key) }
if (gpu.pairs.any { !it.suits }) Box(Modifier.padding(horizontal = 6.dp, vertical = 4.dp)) {
FocusText(stringResource(if (showAllPairs) R.string.gpu_hide_other else R.string.gpu_show_other), pal.signal) { showAllPairs = !showAllPairs }
}
}
PROTON_TAB -> {
val rows = protons.rows.filter { it.installed == null || it.update != null }
if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_new))
@@ -640,28 +592,6 @@ fun ComponentsPage(
val actions: @Composable (String) -> Unit = { l ->
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
when (l) {
GPU_TAB -> {
SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
FocusText(stringResource(R.string.gpu_separately) + " ›", pal.signal) {
val from = at
val title = ctx.getString(R.string.gpu_separately)
if (from == null) { driverPage = "rt"; return@FocusText }
Steps.ask(StepAsk(anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 10.dp, items = 3, handle = {}) {
StepTitle(title)
StepList {
StepChoice(ctx.getString(R.string.comp_runtime_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(1).focusRequester(first)) {
Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "rt"
}
StepChoice(ctx.getString(R.string.comp_display_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(2)) {
Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "panel"
}
}
})
}
}
}
PROTON_TAB -> {
val idle = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning
for (row in protons.rows.filter { it.installedAll.size > 1 }) {
@@ -700,7 +630,10 @@ fun ComponentsPage(
val detail: @Composable (String, Boolean) -> Unit = { l, narrowPage ->
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
val single = narrowPage || maxWidth < 600.dp
Column(Modifier.fillMaxSize()) {
if (l == GPU_TAB) GpuDriversBody(gpu, gpuActions, single, firstRow, slotFocus) { slot, at ->
driverFrom = Origin(at.translate(-pageAt), 10f * density.density); driverPage = slot
}
else Column(Modifier.fillMaxSize()) {
AnimatedContent(l, transitionSpec = { fadeIn(Motion.tw(240)) togetherWith fadeOut(Motion.tw(160)) }, label = "inUse") { k -> bar(k) }
val detailScroll = androidx.compose.runtime.key(l) { rememberScrollState() }
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(detailScroll).padding(top = 12.dp, bottom = 16.dp)) {
@@ -857,7 +790,8 @@ fun ComponentsPage(
Row(Modifier.weight(1f).fillMaxWidth()) {
Box(
(if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight()
.onGloballyPositioned { stackAt = it.positionInRoot() },
.onGloballyPositioned { stackAt = it.positionInRoot() }
.focusProperties { enter = { layerFocus.getValue(layer) } }.focusGroup(),
) {
val tint = rememberUpdatedState(pal.signal)
val local = cardsAt[layer]?.translate(-stackAt)
@@ -897,17 +831,17 @@ fun ComponentsPage(
driverShown?.let { d ->
val body: @Composable () -> Unit = {
Box(Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }) {
if (d == "rt") DriverPage(
if (d == SLOT_RUNTIME) DriverPage(
title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint),
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false,
status = gpu.releaseStatus, checking = gpu.checking, checkedAt = gpu.checkedAt, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false,
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
else DriverPage(
title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint),
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools),
status = gpu.releaseStatus, checking = gpu.checking, checkedAt = gpu.checkedAt, importLabel = stringResource(R.string.comp_import_adrenotools),
canRestore = gpu.canRestoreBundled,
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
@@ -1010,7 +944,7 @@ private fun Stack(
LayerCard(
title = LayerTitle(l),
value = when (l) {
GPU_TAB -> if (gpu.auto) auto else gpu.activePair?.name ?: gpu.activeBundle ?: auto
GPU_TAB -> gpuCardValue(gpu, auto)
PROTON_TAB -> (view?.proton?.name ?: "") + " ▾"
else -> st?.let { if (it.activeFile == null) original else it.inUse } ?: ""
},
@@ -1022,6 +956,9 @@ private fun Stack(
)
}
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(3.dp)) {
StackHeading(stringResource(R.string.comp_this_device))
card(GPU_TAB)
Spacer(Modifier.height(6.dp))
StackHeading(stringResource(R.string.comp_this_proton))
card(PROTON_TAB)
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
@@ -1030,9 +967,6 @@ private fun Stack(
card("dxvk"); card("vkd3d"); card("fex")
}
}
Spacer(Modifier.height(6.dp))
StackHeading(stringResource(R.string.comp_every_proton))
card(GPU_TAB)
}
}
@@ -1534,8 +1468,8 @@ private fun ProgressBit(progress: Int) {
private fun DeviceChip(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = if (s.supported && !s.unsupported) pal.good else AttentionAmber
val label = stringResource(
val tint = if (s.supported && !s.unsupported && !s.display.pending) pal.good else AttentionAmber
val label = if (s.display.pending) stringResource(R.string.gpu_chip_restart, s.gpuName) else stringResource(
when {
s.unsupported -> R.string.comp_device_unsupported
s.supported -> R.string.comp_device_tested
@@ -1590,3 +1524,13 @@ class ProtonRow(
val id: String, val name: String, val installed: String?, val queued: Boolean,
val installedAll: List<String> = listOfNotNull(installed), val update: String? = null,
)
@Composable
private fun gpuCardValue(gpu: GpuDriversState, auto: String): String {
val value = when (gpu.mode) {
GPU_MODE_AUTO -> gpu.activePair?.name?.let { stringResource(R.string.drivers_summary, auto, it) } ?: auto
else -> gpu.activePair?.name?.takeIf { gpu.mode == GPU_MODE_PAIR }
?: if (gpu.display.name == gpu.runtime.name) gpu.display.name else stringResource(R.string.gpu_summary_mix, gpu.display.name, gpu.runtime.name)
}
return if (gpu.display.pending) stringResource(R.string.gpu_summary_restart, value) else value
}
@@ -65,6 +65,7 @@ fun DriverPage(
status: String,
checking: Boolean,
importLabel: String,
checkedAt: Long = Long.MAX_VALUE,
canRestore: Boolean,
onSelect: (String) -> Unit,
onDelete: (String) -> Unit,
@@ -79,6 +80,9 @@ fun DriverPage(
val pal = LocalPalette.current
var confirm by remember { mutableStateOf<DriverRow?>(null) }
BackHandler(onBack = onBack)
LaunchedEffect(Unit) {
if (!checking && System.currentTimeMillis() - checkedAt > 6 * 3_600_000L) onRefresh()
}
val refreshFocus = remember { FocusRequester() }
val selectedFocus = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
@@ -46,10 +46,10 @@ import kotlin.math.min
import kotlin.math.sin
@Composable
internal fun EmptyLibrary(avoid: Rect?, fade: @Composable () -> Unit) {
internal fun EmptyLibrary(avoid: Rect?, wall: (@Composable () -> Unit)? = null, fade: @Composable () -> Unit) {
val clock = rememberMarkClock(avoid)
Box(Modifier.fillMaxSize().onSizeChanged { clock.page = Size(it.width.toFloat(), it.height.toFloat()) }) {
EmptyWall(clock)
if (wall != null) wall() else EmptyWall(clock)
fade()
Mark(clock)
}
@@ -67,7 +67,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val narrow = LocalNarrowPane.current
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
GamesDialogs(a)
GamesDialogs(a, s.steamGames)
val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true })
val pageContext = androidx.compose.ui.platform.LocalContext.current
val onEdit: (Library.SteamGame) -> Unit = { g ->
@@ -154,6 +154,8 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
}
if (!s.shortcutPicker) GameShortcutMenu(g, a)
if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) }
else if (!s.shortcutPicker && g.library != Library.ADDED && g.source == Library.SOURCE_STEAM) EditGameButton { GamesDialogState.steamArt = g }
else if (!s.shortcutPicker && g.library == Library.ADDED && g.source != Library.ADDED && g.gameFiles != null) EditGameButton { GamesDialogState.storeArt = g }
BusyChip(s)
}
}
@@ -328,11 +330,13 @@ internal object GamesDialogState {
var summary by androidx.compose.runtime.mutableStateOf<Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>?>(null)
var editing by androidx.compose.runtime.mutableStateOf<EditRequest?>(null)
var checking by androidx.compose.runtime.mutableStateOf<String?>(null)
var steamArt by androidx.compose.runtime.mutableStateOf<Library.SteamGame?>(null)
var storeArt by androidx.compose.runtime.mutableStateOf<Library.SteamGame?>(null)
var behindEditor: Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>? = null
}
@Composable
private fun GamesDialogs(a: FrontEndActions) {
private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) {
val ctx = androidx.compose.ui.platform.LocalContext.current
val scope = androidx.compose.runtime.rememberCoroutineScope()
val st = GamesDialogState
@@ -366,6 +370,13 @@ private fun GamesDialogs(a: FrontEndActions) {
a.onAddedGameRemoved(folder, appId, name)
}, onDismiss = { st.summary = null; st.checking = null })
}
st.steamArt?.let { game ->
SteamGameArtEditor(games.firstOrNull { it.appId == game.appId && it.library != Library.ADDED } ?: game, onClose = { st.steamArt = null }, onChanged = { a.onSteamGameArtChanged(game.appId) })
}
st.storeArt?.let { game ->
val folder = game.gameFiles?.path
if (folder != null) StoreGameArtEditor(folder, game.name, onClose = { st.storeArt = null }, onChanged = { a.onAddedGamesChanged(listOf(folder)) })
}
st.editing?.let { request ->
AddedGameEditor(
request,
@@ -386,8 +397,8 @@ private fun EditGameButton(onClick: () -> Unit) {
val label = stringResource(R.string.common_edit)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("game:edit").size(40.dp).clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.55f))
modifier = Modifier.paneItem("game:edit").downToFirstTile().size(44.dp).clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
.glideBorder(hot, Shape12, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
@@ -77,6 +77,7 @@ import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
@@ -131,6 +132,9 @@ class FrontEndState(
val showPhantomGate: Boolean = false,
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val downloadSource: String = DownloadSource.MODE_AUTO,
val downloadMirror: String = "",
val downloadPrefix: String = "",
val animationsEnabled: Boolean = true,
val gameStoresEnabled: Boolean = true,
val gameStoresSpeedTier: String = "fast",
@@ -139,6 +143,8 @@ class FrontEndState(
val addedGamesArt: Boolean = true,
val sgdbUserKey: Boolean = false,
val storesShowMature: Boolean = false,
val storesSort: String = SessionPrefs.STORES_SORTS.first(),
val storesView: String = SessionPrefs.STORES_VIEWS.first(),
val storeDownloadsActive: Int = 0,
val updates: UpdatesState = UpdatesState(),
)
@@ -146,12 +152,14 @@ class FrontEndState(
class FrontEndActions(
val onPlay: () -> Unit,
val onPlayDesktopUi: () -> Unit,
val onSteamUrl: (String) -> Unit = {},
val onSteamGame: (Library.SteamGame) -> Unit,
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onExportGameFile: (Library.SteamGame) -> Unit = {},
val onSyncGameFiles: () -> Unit = {},
val onAddGameExe: (path: String) -> Unit = {},
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
val onSteamGameArtChanged: (appId: Int) -> Unit = {},
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
val onAddedGamesArt: (Boolean) -> Unit = {},
val onSgdbKey: (String) -> Unit = {},
@@ -194,11 +202,16 @@ class FrontEndActions(
val onOrientation: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onDownloadSource: (String) -> Unit = {},
val onDownloadMirror: (String) -> Unit = {},
val onDownloadPrefix: (String) -> Unit = {},
val onGameStoresEnabled: (Boolean) -> Unit = {},
val onGameStoresSpeedTier: (String) -> Unit = {},
val onStoresOpenTab: (String) -> Unit = {},
val onNavigated: () -> Unit = {},
val onStoresShowMature: (Boolean) -> Unit = {},
val onStoresSort: (String) -> Unit = {},
val onStoresView: (String) -> Unit = {},
val onLibraryChanged: () -> Unit = {},
val onHomeApp: () -> Unit = {},
val onHomeScreen: (Boolean) -> Unit = {},
@@ -364,7 +377,11 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
LaunchedEffect(s.navigate) {
val target = s.navigate ?: return@LaunchedEffect
if (target == "stores:downloads") { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" } else selected = target
when (target) {
"stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" }
"components" -> a.onComponents(true)
else -> selected = target
}
a.onNavigated()
}
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
@@ -432,6 +449,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = key
}
}
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
if (com.droiddeck.launcher.storage.StorageState.openPending) showRailPage("setup", true)
}
val onRailFocus: (String) -> Unit = { key ->
if (key != railSelection) showRailPage(key, false)
}
@@ -75,6 +75,7 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
@@ -84,6 +85,8 @@ import com.droiddeck.launcher.R
private enum class CheckState { OK, WARN, BUSY }
private const val STORAGE_TAB = 3
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
@@ -164,13 +167,26 @@ internal fun SetupPanel(
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
var editSgdbKey by remember { mutableStateOf(false) }
var editDownloadPrefix by remember { mutableStateOf(false) }
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
if (editDownloadPrefix) TextEntryDialog(
stringResource(R.string.setup_download_prefix), s.downloadPrefix,
onDismiss = { editDownloadPrefix = false },
onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false },
)
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics))
var tab by rememberSaveable { mutableStateOf(0) }
val chipFocus = remember { List(4) { FocusRequester() } }
val chipFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
if (com.droiddeck.launcher.storage.StorageState.openPending) {
com.droiddeck.launcher.storage.StorageState.openPending = false
tab = STORAGE_TAB
}
}
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
@@ -382,12 +398,42 @@ internal fun SetupPanel(
)
}
}
val download: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_download)) {
ChoiceRow(
host, "download-source", stringResource(R.string.setup_download_source), null,
listOf(
DownloadSource.MODE_AUTO to stringResource(R.string.setup_download_source_auto),
DownloadSource.MODE_DIRECT to stringResource(R.string.setup_download_source_direct),
DownloadSource.MODE_MIRROR to stringResource(R.string.setup_download_source_mirror),
DownloadSource.MODE_CUSTOM to stringResource(R.string.setup_download_source_custom),
),
s.downloadSource, onPick = a.onDownloadSource,
)
if (s.downloadSource == DownloadSource.MODE_MIRROR) {
ChoiceRow(
host, "download-mirror", stringResource(R.string.setup_download_mirror), null,
DownloadSource.MIRRORS.map { it.host to it.host },
s.downloadMirror, onPick = a.onDownloadMirror,
)
}
if (s.downloadSource == DownloadSource.MODE_CUSTOM) {
ActionRow(
stringResource(R.string.setup_download_prefix),
s.downloadPrefix.takeIf { it.isNotEmpty() },
stringResource(R.string.mode_sgdb_edit),
onClick = { editDownloadPrefix = true },
)
}
}
}
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { look() }
Column(Modifier.weight(1f)) { device(); art() }
} else { look(); device(); art() }
Column(Modifier.weight(1f)) { device(); art(); download() }
} else { look(); device(); art(); download() }
}
3 -> {
STORAGE_TAB -> StoragePanel(s, a)
4 -> {
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
@@ -2,7 +2,20 @@ package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.geometry.Rect
import androidx.compose.foundation.focusable
import androidx.compose.ui.focus.focusProperties
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.outlined.Monitor
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.runtime.LaunchedEffect
@@ -58,6 +71,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.focus.onFocusChanged
import kotlinx.coroutines.launch
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -74,6 +90,17 @@ class PairRow(
val active: Boolean,
)
const val GPU_MODE_AUTO = "auto"
const val GPU_MODE_PAIR = "pair"
const val GPU_MODE_MIX = "mix"
class DriverSlot(
val name: String = "",
val source: String = "",
val pending: Boolean = false,
val status: String = "",
)
class GpuDriversState(
val gpuName: String = "",
val gpuFamily: String = "",
@@ -96,6 +123,11 @@ class GpuDriversState(
val androidDownloads: List<DownloadRow> = emptyList(),
val canRestoreBundled: Boolean = false,
val activeBundle: String? = null,
val mode: String = GPU_MODE_AUTO,
val display: DriverSlot = DriverSlot(),
val runtime: DriverSlot = DriverSlot(),
val canRestart: Boolean = true,
val checkedAt: Long = 0L,
) {
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
}
@@ -113,6 +145,8 @@ class GpuDriversActions(
val onImportAndroid: () -> Unit = {},
val onImportZip: () -> Unit = {},
val onRestoreBundled: () -> Unit = {},
val onMode: (String) -> Unit = {},
val onRestart: () -> Unit = {},
)
@Composable
@@ -168,3 +202,257 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
}
}
}
private const val STALE_MS = 6 * 3_600_000L
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
internal fun GpuDriversBody(
gpu: GpuDriversState,
actions: GpuDriversActions,
single: Boolean,
first: FocusRequester,
slotFocus: Map<String, FocusRequester>,
onOpenSlot: (String, Rect) -> Unit,
) {
LaunchedEffect(Unit) {
if (!gpu.checking && gpu.busy == null && System.currentTimeMillis() - gpu.checkedAt > STALE_MS) actions.onRefresh()
}
val scroll = rememberScrollState()
val scope = rememberCoroutineScope()
val placed = remember { arrayOfNulls<LayoutCoordinates>(2) }
// Focusing the top mode shows as much of the in-use card above it as fits, without hiding the mode itself.
val revealCard = {
val (viewport, top) = placed
if (viewport != null && top != null && viewport.isAttached && top.isAttached) scope.launch {
val bottom = viewport.localPositionOf(top, Offset(0f, top.size.height.toFloat())).y + scroll.value
scroll.animateScrollTo((bottom - viewport.size.height).toInt().coerceAtLeast(0))
}
}
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxSize().onGloballyPositioned { placed[0] = it }.verticalScroll(scroll).padding(bottom = 16.dp)
.focusProperties { enter = { first } }.focusGroup(),
) {
InUseCard(gpu, actions, first)
PanelHeading(stringResource(R.string.gpu_mode_title))
val modes = listOf(
Triple(GPU_MODE_AUTO, R.string.common_auto, R.string.gpu_mode_auto_detail),
Triple(GPU_MODE_PAIR, R.string.gpu_mode_pair, R.string.gpu_mode_pair_detail),
Triple(GPU_MODE_MIX, R.string.gpu_mode_mix, R.string.gpu_mode_mix_detail),
)
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
val (id, title, detail) = modes[i]
PanelRow(
stringResource(title), stringResource(detail), gpu.mode == id,
modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier)
.then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier),
onClick = { if (gpu.mode != id) actions.onMode(id) },
)
}
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
}
when (gpu.mode) {
GPU_MODE_AUTO -> AutoBody(gpu)
GPU_MODE_PAIR -> PairBody(gpu, actions)
else -> MixBody(single, actions, slotFocus, onOpenSlot)
}
}
}
@Composable
private fun InUseCard(gpu: GpuDriversState, actions: GpuDriversActions, first: FocusRequester) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(14.dp)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(colors.surface).border(1.dp, pal.line, shape).padding(horizontal = 16.dp, vertical = 12.dp),
) {
SlotLine(Icons.Outlined.Monitor, stringResource(R.string.comp_display_driver), gpu.display)
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
SlotLine(Icons.Outlined.SportsEsports, stringResource(R.string.comp_runtime_driver), gpu.runtime)
if (gpu.busy != null) LinearProgressIndicator(
progress = { (gpu.percent.coerceAtLeast(0)) / 100f }, modifier = Modifier.fillMaxWidth(),
)
if (gpu.display.pending) RestartBanner(gpu.display.name, gpu.canRestart, first, actions.onRestart)
}
}
@Composable
private fun SlotLine(icon: ImageVector, label: String, slot: DriverSlot) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(colors.surfaceVariant)) {
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontSize = 12.5.sp, color = colors.onSurfaceVariant)
Pill(slot.source, pal.line2.copy(alpha = 0.6f), colors.onBackground)
}
Text(slot.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (slot.status.isNotEmpty()) Text(
slot.status, fontSize = 12.5.sp, color = if (slot.pending) AttentionAmber else colors.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@Composable
private fun RestartBanner(target: String, canRestart: Boolean, afterLater: FocusRequester, onRestart: () -> Unit) {
var later by rememberSaveable(target) { mutableStateOf(false) }
if (later) return
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(AttentionAmber.copy(alpha = 0.12f))
.border(1.dp, AttentionAmber.copy(alpha = 0.45f), shape).padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(Icons.Outlined.Refresh, contentDescription = null, tint = AttentionAmber, modifier = Modifier.size(20.dp))
Text(
stringResource(if (canRestart) R.string.gpu_restart_banner else R.string.gpu_restart_blocked),
fontSize = 13.5.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f),
)
FocusText(stringResource(R.string.gpu_restart_later), MaterialTheme.colorScheme.onBackground) {
runCatching { afterLater.requestFocus() }
later = true
}
if (canRestart) FocusText(stringResource(R.string.gpu_restart_now), AttentionAmber, onClick = onRestart)
}
}
@Composable
private fun AutoBody(gpu: GpuDriversState) {
val colors = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
PanelTitle(
stringResource(if (gpu.activePair != null) R.string.gpu_auto_title else R.string.gpu_auto_builtin_title, gpu.gpuName),
stringResource(R.string.gpu_auto_reason, gpu.gpuFamily),
)
gpu.activePair?.let { p -> PanelRow(p.name, "${p.version} · ${p.detail}", selected = true, onClick = null) }
if (gpu.autoStatus.isNotEmpty()) Text(gpu.autoStatus, fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
@Composable
private fun PairBody(gpu: GpuDriversState, actions: GpuDriversActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var showAll by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PanelTitle(stringResource(R.string.gpu_pair_title), stringResource(R.string.gpu_pair_hint))
val rows = gpu.pairs.filter { (it.installed || it.complete) && (it.suits || showAll) }
if (rows.isEmpty()) Text(
stringResource(if (gpu.checking) R.string.gpu_checking else R.string.gpu_none_listed),
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
for (p in rows) PanelRow(
p.name, "${p.version} · ${p.detail}", selected = p.active,
badge = if (p.recommended) stringResource(R.string.gpu_autos_pick) else null,
trailing = when {
gpu.busy == p.key -> "${gpu.percent.coerceAtLeast(0)}%"
p.installed -> stringResource(R.string.gpu_installed)
else -> stringResource(R.string.common_download)
},
enabled = gpu.busy == null,
onClick = { if (!p.active) actions.onPair(p.key) },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (gpu.pairs.any { !it.suits }) FocusText(stringResource(if (showAll) R.string.gpu_hide_other else R.string.gpu_show_other), pal.signal) { showAll = !showAll }
FocusText(stringResource(R.string.comp_import_zip), pal.signal, onClick = actions.onImportZip)
}
}
}
@Composable
private fun MixBody(single: Boolean, actions: GpuDriversActions, slotFocus: Map<String, FocusRequester>, onOpenSlot: (String, Rect) -> Unit) {
val pal = LocalPalette.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
PanelTitle(stringResource(R.string.gpu_mix_title), stringResource(R.string.gpu_mix_hint))
val at = remember { mutableMapOf<String, Rect>() }
val slotButton: @Composable (String, Int, Modifier) -> Unit = { slot, label, mod ->
SecondaryButton(
stringResource(label),
modifier = mod.focusRequester(slotFocus.getValue(slot)).onGloballyPositioned { at[slot] = it.boundsInRoot() },
) { onOpenSlot(slot, at[slot] ?: Rect.Zero) }
}
val buttons: @Composable (Modifier) -> Unit = { mod ->
slotButton(SLOT_DISPLAY, R.string.gpu_change_display, mod)
slotButton(SLOT_RUNTIME, R.string.gpu_change_runtime, mod)
}
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { buttons(Modifier.fillMaxWidth()) }
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { buttons(Modifier.weight(1f)) }
FocusText(stringResource(R.string.comp_import_zip), pal.signal, onClick = actions.onImportZip)
}
}
internal const val SLOT_DISPLAY = "panel"
internal const val SLOT_RUNTIME = "rt"
@Composable
private fun PanelHeading(text: String) {
Text(text.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun PanelTitle(title: String, hint: String) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(hint, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun Pill(text: String, fill: Color, ink: Color) {
if (text.isEmpty()) return
Text(
text, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, color = ink, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(fill).padding(horizontal = 7.dp, vertical = 3.dp),
)
}
@Composable
private fun PanelRow(
title: String,
detail: String,
selected: Boolean,
modifier: Modifier = Modifier,
badge: String? = null,
trailing: String? = null,
enabled: Boolean = true,
onClick: (() -> Unit)?,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else colors.surface)
.glideBorder(hot, shape, pal.signal, if (selected) pal.signal.copy(alpha = 0.7f) else pal.line)
.then(
if (onClick != null) Modifier.hoverable(src)
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick)
else Modifier,
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape),
) { if (selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) }
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = if (selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (detail.isNotEmpty()) Text(detail, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (badge != null) Pill(badge, pal.signal.copy(alpha = 0.16f), pal.signal)
if (trailing != null) Text(trailing, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1)
}
}
@@ -27,6 +27,7 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.BootMovie
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionService
@@ -77,12 +78,18 @@ class ModeSettings(
val fexPreset: String? = null,
val forceSsbs: Boolean = false,
val syncBackend: String? = null,
val picturePosition: String? = null,
val stretch16x9: Boolean? = null,
val steamRepairQueued: Boolean = false,
val mangoapp: Boolean = true,
val steamController: String? = null,
val unfoldedControls: Boolean? = null,
val runSteamAtStartup: Boolean = false,
val bootMovie: String? = null,
val bootMovieSound: Boolean = true,
val bootMovieCustomName: String? = null,
val bootMovieImporting: Boolean = false,
val bootMovieProgress: Int = -1,
val wifiDiscovery: Boolean? = null,
val wifiDiscoveryPermission: Boolean = false,
val wifiDiscoveryLocation: Boolean = false,
@@ -120,12 +127,17 @@ class ModeSettingsActions(
val onFexPreset: (String) -> Unit = {},
val onForceSsbs: (Boolean) -> Unit = {},
val onSyncBackend: (String) -> Unit = {},
val onPicturePosition: (String) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onUnfoldedControls: (Boolean) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onBootMovie: (String) -> Unit = {},
val onBootMovieSound: (Boolean) -> Unit = {},
val onImportBootMovie: () -> Unit = {},
val onRemoveBootMovie: () -> Unit = {},
val onWifiDiscovery: (Boolean) -> Unit = {},
val onWifiDiscoverySettings: () -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
@@ -167,6 +179,13 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution)
s.picturePosition?.let { position ->
ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null,
listOf("top" to stringResource(R.string.display_position_top),
"bottom" to stringResource(R.string.display_position_bottom),
"center" to stringResource(R.string.display_position_center)),
position, onPick = a.onPicturePosition)
}
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
@@ -408,6 +427,41 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) {
ChoiceRow(
host, "boot-movie", stringResource(R.string.boot_movie_video), stringResource(R.string.common_applies_next_session),
buildList {
add(BootMovie.DROIDDECK_30 to stringResource(R.string.boot_movie_droiddeck, 30))
add(BootMovie.DROIDDECK_60 to stringResource(R.string.boot_movie_droiddeck, 60))
s.bootMovieCustomName?.let { add(BootMovie.CUSTOM to it) }
}, s.bootMovie,
note = stringResource(R.string.boot_movie_note), onPick = a.onBootMovie,
)
ToggleRow(
host, "boot-movie-sound", stringResource(R.string.boot_movie_sound),
stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound,
)
SettingsRow(
stringResource(R.string.boot_movie_custom),
stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS),
) {
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
when {
s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress)
s.bootMovieImporting -> stringResource(R.string.common_working)
else -> stringResource(R.string.boot_movie_import)
},
enabled = !s.bootMovieImporting,
progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f },
onClick = a.onImportBootMovie,
)
if (s.bootMovieCustomName != null) SecondaryButton(
stringResource(R.string.boot_movie_remove), enabled = !s.bootMovieImporting, onClick = a.onRemoveBootMovie,
)
}
}
}
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
@@ -23,6 +23,7 @@ import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.KeyboardHost
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
@@ -45,6 +46,7 @@ class SecondScreenPresentation(
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
private val onClose: () -> Unit,
private val pad: PadBridge? = null,
) : Presentation(sessionActivity, display) {
private val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
@@ -114,7 +116,7 @@ class SecondScreenPresentation(
when (currentMode) {
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
SecondScreenMode.TERMINAL -> renderTerminal()
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams())
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams())
SecondScreenMode.NONE -> Unit
}
}
@@ -5,6 +5,7 @@ internal enum class SessionDrawerPage(val firstControl: String) {
SECOND_SCREEN("second-screen:none"),
DISPLAY("hud"),
EFFECTS("look"),
VR("steamvr"),
GAMES("anisotropy"),
SESSION("suspend"),
}
@@ -151,6 +151,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
SessionDrawerPage.VR -> R.string.vr_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.SESSION -> R.string.mode_session
@@ -160,6 +161,7 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
SessionDrawerPage.VR -> VrHeadsetIcon
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
@@ -209,6 +211,7 @@ class DrawerActions(
val cardboard: Cardboard = Cardboard.OFF,
val cardboardSensor: Boolean = false,
val steamVr: SteamVr.Status? = null,
val immersiveSteamVr: Boolean = false,
val textureAnisotropy: Int? = 0,
val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF,
val secondScreenMode: SecondScreenMode,
@@ -233,6 +236,10 @@ class DrawerActions(
val onSuspendPolicy: (String) -> Unit,
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onBackActionsInverted: (Boolean) -> Unit,
val lowLatencyController: Boolean = false,
val lowLatencyTouch: Boolean = false,
val onLowLatencyController: (Boolean) -> Unit = {},
val onLowLatencyTouch: (Boolean) -> Unit = {},
val onTouch: (String) -> Unit,
val onFexPreset: (String) -> Unit,
val onUpscaler: (Int) -> Unit = {},
@@ -280,6 +287,9 @@ class DrawerActions(
val onGameNoSteam: (Long, Boolean) -> Unit = { _, _ -> },
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
val onGameComponent: (appId: Long, comp: String, file: String?) -> Unit = { _, _, _ -> },
val defaultProtonId: String? = null,
val onDefaultProton: (protonId: String) -> Unit = {},
val onGameProton: (appId: Long, protonId: String?) -> Unit = { _, _ -> },
)
@OptIn(ExperimentalComposeUiApi::class)
@@ -448,12 +458,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
}
CardboardGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.EFFECTS -> {
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
}
SessionDrawerPage.VR -> {
SteamVrGroup(a) { key -> focus.track(page, key) }
CardboardGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
a.padType?.let { type ->
@@ -497,6 +510,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
}
SettingsGroup(stringResource(R.string.drawer_latency)) {
ToggleRow(host, "low-latency-controller", stringResource(R.string.drawer_low_latency_controller),
stringResource(R.string.drawer_low_latency_controller_hint), a.lowLatencyController,
chipModifier = focus.track(page, "low-latency-controller"), onChange = a.onLowLatencyController)
ToggleRow(host, "low-latency-touch", stringResource(R.string.drawer_low_latency_touch),
stringResource(R.string.drawer_low_latency_touch_hint), a.lowLatencyTouch,
chipModifier = focus.track(page, "low-latency-touch"), onChange = a.onLowLatencyTouch)
}
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
DrawerOutlineButton(stringResource(R.string.drawer_pc_keyboard), modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) {
@@ -903,17 +924,32 @@ private fun CardboardGroup(host: MenuHost, a: DrawerActions, track: (String) ->
}
if (c.headTracking && a.cardboardSensor)
ActionRow(stringResource(R.string.cardboard_recenter), null, stringResource(R.string.cardboard_recenter_button), a.onCardboardRecenter)
}
}
@Composable
private fun SteamVrGroup(a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.steamvr_title)) {
val vrLabel = stringResource(R.string.cardboard_steamvr)
val enterLabel = stringResource(if (a.immersiveSteamVr) R.string.steamvr_enter else R.string.cardboard_steamvr_start)
@Composable fun button(label: String, text: String, action: String) {
SettingsRow(label, null) {
SecondaryButton(text, modifier = track("steamvr"), onClick = { a.onSteamVr(action) })
}
}
when (a.steamVr) {
SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_install_hint),
stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") })
SteamVr.Status.NOT_INSTALLED -> button(vrLabel, stringResource(R.string.cardboard_steamvr_install), "install")
SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {}
SteamVr.Status.READY -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_ready_hint),
stringResource(R.string.cardboard_steamvr_start), { a.onSteamVr("start") })
SteamVr.Status.RUNNING -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_running_hint),
stringResource(R.string.cardboard_steamvr_stop), { a.onSteamVr("stop") })
SteamVr.Status.READY -> button(vrLabel, enterLabel, "start")
SteamVr.Status.RUNNING -> {
if (a.immersiveSteamVr) button(vrLabel, enterLabel, "start")
SettingsRow(vrLabel, null) {
SecondaryButton(stringResource(R.string.cardboard_steamvr_stop),
modifier = track(if (a.immersiveSteamVr) "steamvr-stop" else "steamvr"), onClick = { a.onSteamVr("stop") })
}
}
SteamVr.Status.UNAVAILABLE -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_unavailable)) {}
null -> {}
null -> SettingsRow(vrLabel, stringResource(R.string.steamvr_checking)) {}
}
}
}
@@ -1172,9 +1208,21 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
return
}
SettingsGroup(stringResource(R.string.drawer_page_components)) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) {
ProfileSummaryRow(stringResource(R.string.comp_proton), gameProfile.proton.value)
}
DrawerStackedChoice(
host = host,
key = "game-cmp-proton",
label = stringResource(R.string.comp_proton),
hint = if (gameProfile.proton.inherited && gameProfile.proton.value.isNotEmpty()) {
stringResource(R.string.drawer_component_inherited, gameProfile.proton.value)
} else {
null
},
options = listOf<Pair<String?, String>>(null to stringResource(R.string.comp_game_use_general)) +
snap.protons.map { it.proton.id to it.proton.name },
selected = if (gameProfile.proton.inherited) null else gameProfile.protonId,
chipModifier = track("cmp-proton"),
onPick = { id -> a.onGameProton(gameProfile.appId, id) },
)
for (comp in ComponentsManager.COMPONENTS) {
val value = gameProfile.components.getValue(comp)
val options: List<Pair<String?, String>> =
@@ -1205,8 +1253,7 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
return
}
val running = snap.protons.filter { it.inUseByGame }
var pick by rememberSaveable { mutableStateOf<String?>(null) }
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
val view = snap.protons.firstOrNull { it.proton.id == a.defaultProtonId } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
SettingsGroup(stringResource(R.string.drawer_running_now)) {
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value
@@ -1247,7 +1294,7 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
view.reappliedAt.takeIf { it > 0 }?.let { stringResource(R.string.drawer_reapplied, java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(it * 1000))) },
).joinToString(" · "),
snap.protons.map { it.proton.id to it.proton.name }, p.id,
chipModifier = track("cmp-proton"), onPick = { pick = it },
chipModifier = track("cmp-proton"), onPick = { if (it != p.id) a.onDefaultProton(it) },
)
val originalLabel = stringResource(R.string.drawer_original)
val build = ComponentsManager.safeName(p.version)
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Rect
@@ -66,13 +67,23 @@ import coil.request.ImageRequest
import coil.size.Scale
import coil.transform.RoundedCornersTransformation
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val WALL_MIN_GAMES = 4
private const val WALL_DISTINCT = 40
private const val DRIFT_STEP_NS = 15_000_000L
@Composable
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(Unit) { com.droiddeck.launcher.frontend.SteamFeatured.load(ctx) }
val featured = com.droiddeck.launcher.frontend.SteamFeatured.games
val signedIn = s.offlineAccount != null
val owned by produceState(emptyList<Pair<Int, java.io.File>>(), signedIn) {
value = if (!signedIn) emptyList() else withContext(Dispatchers.IO) { runCatching { Library.steamLibraryArt(ctx).shuffled() }.getOrDefault(emptyList()) }
}
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
val narrow = LocalNarrowPane.current
var words by remember { mutableStateOf<Rect?>(null) }
@@ -100,9 +111,12 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
when {
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.isEmpty() -> EmptyLibrary(
avoid = words,
wall = if (owned.isEmpty() && featured.isEmpty()) null else { { CapsuleWall(emptyList(), owned = owned, featured = featured) } },
) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> CapsuleWall(games)
else -> CapsuleWall(games, owned = owned, featured = featured)
}
},
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
@@ -143,10 +157,24 @@ internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
owned: List<Pair<Int, java.io.File>> = emptyList(),
featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(),
) {
val pal = LocalPalette.current
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
val padding = remember(games, owned, featured) {
val shown = games.map { it.appId }.toMutableSet()
val library = owned.filter { shown.add(it.first) }.map { (id, art) -> id.toString() to art }
val store = if (owned.isEmpty()) featured.filter { shown.add(it.appId) }.map { it.name to it.art } else emptyList()
(library + store).take((WALL_DISTINCT - games.size).coerceAtLeast(0))
}
TiltedWall(games.size + padding.size, driftMs, modifier) { i ->
when {
i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
i - games.size < padding.size -> padding[i - games.size].let { (seed, art) -> WallCapsule(artBrush(hueOf(seed)), art) }
else -> blank
}
}
}
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more