Compare commits

...
Author SHA1 Message Date
The412Banner 7d51395ec7 feat: Performance boost switch, with Samsung's performance SDK on Samsung phones
One switch on the Performance page turns on the boost for a session:
- GameThreadHints (ADPF, kmnkv1 #682) now runs on every Android 12+ phone
  when the switch is on. It stays on by default only on the Odin 3, where
  it was measured; elsewhere it is off until turned on.
- On Samsung phones the switch also holds Samsung's CPU, GPU and memory-bus
  presets through the Galaxy Performance SDK while the game presents frames
  and falls behind the panel. It lets go when the game holds pace, stops
  presenting, the phone is severely hot or battery saver is on. The presets
  are renewed every 2 s with a 5 s timeout, so Samsung's service lets go on
  its own if the app dies.

Samsung's licence does not allow passing its SDK jar on, so DroidDeck ships
without it. A Samsung owner downloads the SDK zip from Samsung and imports
it once (the picker opens when the switch is turned on without it). The jar
is converted to dex on the phone with dx (dalvik-dx 16.0.1, Apache-2.0)
and loaded with DexClassLoader.
2026-10-30 23:13:06 +00:00
MaxsTechReview 8cf01ebb77 fix: Linux games on an NTFS USB drive start the first time instead of failing to load a library (#1850)
* fix: a read-only file on a USB drive no longer fails to close the first time it is read

On the RedMagic Astra 2, Android's NTFS USB drive returns EIO from close() for a file opened read-only when that first read updates its access time; the update itself succeeds and the descriptor is released. The x86 loader under FEX treats a failed close as fatal, so every Linux game newly installed or updated on such a drive failed to start once (libfreetype.so.6: cannot close file descriptor: Input/output error). The session library's close() now reports success for EIO on read-only descriptors; errors on descriptors opened for writing are still returned.

* Check the read-write close still reports EIO
2026-10-30 18:36:08 -04:00
MaxsTechReview 7e47db2e2a fix: a game with a launch-option picker is no longer closed while the picker is open (MK11) (#1869)
fix: a game with a launch-option picker is no longer closed while the picker is open
2026-10-30 18:16:11 -04:00
MaxsTechReview 484afffffd Translate the strings added since the last translation pass (#1855)
* Translate the strings added since the last translation pass

* Fix translation wording from review

Use the Android settings labels for the pairing guide in bg, cs, da, de, fi, ru and zh-CN, correct the frame limit divisibility wording in de and nl, describe owned games without implying purchase in hi and ru, fix the VRAM label in es-419, describe GOG replacement files correctly in tr and name the Guide button in zh-CN.
2026-10-30 18:08:05 -04:00
MaxsTechReview 0b1af34a5f fix: library filter, sort and display menus aren't cut off in portrait (#1827)
* fix: library menus open toward the side with room, so in portrait they are no longer cut off

In portrait the Filter, Sort, Display and Collections buttons sit at the left of the screen, but their menus still grew to the left and were squeezed into the space before the button, cutting the labels. A menu now opens the other way when its own side is narrower than the menu and the other side has more room.

* fix: step-out menus keep their outline with the content when a resize flips the side

* fix: step-out menus still take keyboard focus when the layout shifts while opening
2026-10-30 17:04:59 -04:00
MaxsTechReview 2ff682a4f5 fix: a folder of games with an extra non-game folder still lists each game (Rance 01) (#1822)
* fix: a folder of games still splits into its games when it also holds a folder with no game, as with Rance 01 in a VNs folder

A visual-novel folder on game storage held a steamapps folder with no exe in it. The group check asked every subfolder to hold a game, so the whole folder stayed one game and a pick in Rance 01 switched that game instead of adding its own. Folders without a game and Steam library folders no longer count, and old games folders being moved to entries split such a folder the same way.

* Check a folder's own entry, removal and store ownership before splitting it, and keep folders with redist or extras as one game

An old Games folder that was removed keeps its games removed after the split, a folder already added stays as it is, and a store game's folder is never split.

* fix: a split games folder keeps the game it launched and that game's settings

When an old games folder entry is split into one entry per game, the
folder holding the selected exe is kept even if ranking left it out (or
the parent stays whole when the exe sits directly in it), and the
parent's name, app ID, launch options and other settings carry over to
that game without overwriting settings it already has.

* fix: a split games folder never migrates steamapps and keeps the launched exe

A saved exe under a steamapps or hidden folder keeps the old entry whole
instead of adding that folder as a game, and the game that inherits the
old entry's settings keeps the exe the old entry launched when nothing
was picked.
2026-10-30 16:58:23 -04:00
MaxsTechReview 18f6305977 fix: banking apps no longer refuse to open while DroidDeck is installed (#1824)
* fix: banking apps open again with DroidDeck installed; the shortcuts accessibility service is listed only once the user sets it up

0.6.7 added HomeButtonService, a key-filtering accessibility service, enabled in the manifest. Android lists it as an installed accessibility service on every phone, and banking apps such as UBS refuse to start while one from outside the Play Store is installed. The service is now disabled by default, turned on when the user opens the Shortcuts setup, turned back off if they leave it unset, and kept on for anyone who already uses it.

* Keep the shortcuts service declared and turn it off at start unless the user chose it, so an update never drops their choice

A failed read of the enabled-services setting now changes nothing.

* Hide the unchosen shortcuts service right after an update, before DroidDeck is opened

A package-replaced receiver in its own light process runs the same listing sync as startup. The manifest default stays enabled because Android drops an unavailable service from the enabled-services setting, which would erase the choice of users updating from 0.6.7.

* Hide the shortcuts service as soon as the user turns it off in Accessibility settings
2026-10-30 16:55:09 -04:00
MaxsTechReview 7bc3529722 fix(audio): DirectAudio no longer crackles where Android refuses low-latency output (#1862)
* fix(audio): DirectAudio no longer crackles where Android refuses low-latency output

On devices where Android won't give the game a low-latency output (AYN Thor,
Android 13), DirectAudio kept Wine's 10 ms period and 12 ms buffer. An
emulated game audio thread can't keep that pace there, so audio crackled.
The session now checks for low-latency output and, only when it is refused,
asks DirectAudio for a 20 ms period and a 60 ms buffer.

DirectAudio's settings are now AUDIO_DIRECT_* everywhere in DroidDeck and are
handed to the driver under the names it reads when DirectAudio is set up.
AUDIO_DIRECT_MS and AUDIO_DIRECT_PERIOD_MS can be set per game.

* fix(audio): game profile DirectAudio settings and the microphone-off setting reach the driver
2026-10-30 16:34:45 -04:00
Kurt Himebauch 9cc56631cd feat: test and local builds show a faint PR, commit and title watermark (#1863) 2026-10-30 15:29:41 -04:00
Kurt Himebauch a5b035b247 test: the required-status gate expects the skip-on-superseded condition (#1866) 2026-10-30 15:25:28 -04:00
MaxsTechReview a8cebd024b fix(ci): required-check test matches the gate's !cancelled() condition (#1864)
fix(ci): the required-check test expects the gate's !cancelled() condition
2026-10-30 15:20:30 -04:00
MaxsTechReview 7c49eef79f fix(dual-screen): keep the bottom-screen controls up while a game loads (#1840)
* fix(dual-screen): keep the bottom-screen controls up while a game loads

The session opened its bottom-screen trackpad only at the game's first frame. The launcher's bottom menu closes when a game starts, so on dual-screen handhelds the bottom screen showed whatever Android app was behind it for the whole load, which is long for Xbox games while Xodus prepares them. The controls now open when the session starts. Devices without a built-in second screen, and players who turned the in-game controls off, are unchanged.

* fix(dual-screen): pick the bottom-screen panel again once the session's controller is known

Opening the bottom-screen controls at session start can run before SessionService has prepared the Steam Deck pad, while SessionState.deckPad is still false, so the trackpad could be chosen where the Deck controls belong. That early choice is now provisional: at the game's first frame it switches to the panel the remembered choice and the real deckPad give, unless the player has already picked one.

* fix(dual-screen): keep the loading-time panel choice through PiP, background and new sessions

* fix(dual-screen): retry a terminal panel opened before it was ready and keep a manual close made during loading
2026-10-30 14:32:34 -04:00
Kurt Himebauch c8c5e22a34 fix: new Steam installs start with shader pre-caching off (#1861) 2026-10-30 14:17:45 -04:00
Kurt Himebauch fa4a54f64a Default game library to vertical covers (#1857) 2026-10-30 14:09:44 -04:00
MaxsTechReview 83980035c6 fix(desktop): video wallpapers no longer race ahead after DroidDeck returns from the background (#1833)
* fix(desktop): video wallpapers no longer race ahead after DroidDeck returns from the background

* test: give the vrclient loader harness the clock syscall hook
2026-10-30 13:58:49 -04:00
MaxsTechReview 8f7cdb7106 fix: Battle.net scales its windows to large desktops instead of opening tiny on high-resolution screens (#1832) 2026-10-30 13:58:44 -04:00
MaxsTechReview 9a1334b73b fix: on dual-screen handhelds DroidDeck no longer jumps back over apps opened from outside it (#1828)
fix: the bottom-screen panel no longer pulls DroidDeck back over an app opened on the main screen from outside DroidDeck

On dual-screen handhelds the panel took focus and moved DroidDeck's task forward whenever another app opened over it on the main screen, unless DroidDeck had opened that app itself. A frontend such as iisu, or Settings opened from a shortcut, was covered again within a fraction of a second. The panel now stops reclaiming once the launcher is paused, and starts again when it resumes.
2026-10-30 13:58:40 -04:00
MaxsTechReview ab949a4a5a fix(runtime): the x86-64 Steam client starts again on the Ubuntu runtime (liblzo2-2 for FEX) (#1825)
* fix(runtime): ship liblzo2-2 so the x86-64 Steam client can install FEX

FEX's unsquashfs links liblzo2. The Ubuntu runtime did not ship it, so the FEX check failed on every build, the x86-64 client was never set up, and the session went straight back to the home screen. Only phones with the desktop installed had it.

* fix: the x86-64 Steam client starts again after it updates itself under FEX

On its first start the x86-64 client downloads a 500 MB update and, under FEX, ends on an illegal instruction (rc=132) instead of asking for a restart with rc=42, so the session ended there. The client is started again once.
2026-10-30 13:58:35 -04:00
MaxsTechReview b91e8fdeab fix: a session in picture-in-picture draws at 30 fps instead of the full refresh (#1842)
* fix: a session in picture-in-picture draws at 30 fps instead of the full refresh

Since auto picture-in-picture became the default (#1727), pressing Home leaves the session running in a small window that takes no input. A game kept drawing at its full rate there: on a REDMAGIC Astra 2 Among Us in picture-in-picture drew 3.31 W, the same as playing it and about 1.05 W more than a suspended session. While in picture-in-picture the compositor now paces the session at 30 fps (or a lower limit already set). Mirroring to glasses keeps the glasses rate, and the display mode request ignores the picture-in-picture limit so the app in front is not slowed.

* fix: keep the no-argument output limit the glasses pacing tests read

* fix: hold scene renders to the output limit and keep frame generation off in picture-in-picture

A frame limit only paced dmabuf buffer releases, so a shared-memory client was still drawn on every vsync with its frame callbacks sent at the panel's rate. The vsync tick now waits until the limit's interval, less half a refresh, has passed since the last scene. Frame generation is disarmed while picture-in-picture is shown and armed again when it ends; frame generation requests are applied in order.

* Give the commit render harness the frame limit hold

* Hold scene renders to a frame limit deadline, also on the fallback timer, and keep frame generation for glasses mirroring in picture in picture
2026-10-30 13:29:10 -04:00
MaxsTechReview 021e7153fa fix: Automatic dual screen needs a real second touch panel (Odin keeps its side rail) (#1801)
* fix: AYN Odin handhelds are single-screen in automatic dual-screen mode

* Use a second screen only when more than one physical screen is lit, instead of excluding AYN models

* fix: open the session panel when the second screen turns on, and keep the On mode's display choice

* fix: let the session panel open again after its screen goes away and returns

* fix: Automatic dual screen needs a touchscreen for the second panel instead of counting lit displays

* fix: check the second screen again when a touchscreen comes back after the display

* fix: check the second screen again when a touchscreen is added or removed, and on resume

* Keep the second screen panel closed once the session has ended
2026-10-30 13:19:21 -04:00
Kurt Himebauch 524292f457 fix: drop the runtime installs on first Play chip from the Steam home (#1854) 2026-10-30 13:04:16 -04:00
Kurt Himebauch 92c1560f63 feat: Steam's sleep-after timers start every session as Disabled (#1848) 2026-10-30 12:13:12 -04:00
MaxsTechReview 41615d2460 fix: Steam's UI runs at the panel's full refresh rate again (#1841)
#1809 held Steam's own UI to an even share of the refresh near 60 fps whenever it had focus. On 120 Hz and faster screens Big Picture then felt locked at 60 while games ran at the full rate, and players reported it as a regression. The compositor now releases Steam UI buffers unpaced again, like a game's. The larger swapchain stays limited to games, so the client UI still uses gamescope's default image count.
2026-10-30 11:10:03 -04:00
Kurt Himebauch bc1a010411 fix: launching a game from a frontend starts it instead of sitting on the home screen (#1844) 2026-10-30 10:34:30 -04:00
MaxsTechReview d31f234f74 fix(xbox): games run from their own folder on the short drive, not the drive root (#1804)
* fix(xbox): keep the game in its own folder on the short drive instead of at the drive root

* fix(xbox): SD card Xbox games can read their own package files

Xodus mounts a store library on the selected SD card at /mnt/droiddeck-stores/<card> and links the launch view to it, but game sessions only bound that library as /mnt/droiddeck-sd. The links were dangling inside the game, so MicrosoftGame.Config, appxmanifest.xml and XCurl.dll could not be read and GDK games stopped with "Failed to initialize GDK". Game sessions now bind the same store roots Xodus uses.

* fix(xbox): give the SD Steam library its own store path when another store root shares the card

externalRootsForXodus added the selected Steam library's Games folder with the card's guest path even when another store root on the same card already used it, so both bound to one destination. It now takes a path-derived suffix in that case, the same way externalRoots separates its own roots.
2026-10-30 10:10:23 -04:00
FX1922 6ffe675a57 fix: match the game page's check for updates, uninstall and edit art buttons to the frosted design (#1831) 2026-10-30 09:31:05 -04:00
MaxsTechReview fb5af9d35e fix(runtime): cutscenes and in-game videos play again with DroidDeck-Proton (libva-drm2, libva-x11-2) (#1823)
fix(runtime): ship libva-drm2 and libva-x11-2 so DroidDeck-Proton's video demuxer loads

DroidDeck-Proton's ffmpeg links libva-drm and libva-x11. The runtime only had libva2, so winedmo.so failed to load and Media Foundation video stopped: Resident Evil Revelations hung on its loading screen with "Failed to create demuxer, status 0xc000007a", and RE engine and Kingdom Hearts cutscenes broke. Installing the desktop hid it because Plasma pulls both in.
2026-10-30 09:18:09 -04:00
Kurt Himebauch b75060c280 feat: cap the frame rate in session for games launched without Steam (#1813) 2026-10-30 07:39:31 -04:00
FX1922 67d391958f feat: apply itch updates as butler patches (#1829) 2026-10-30 07:38:06 -04:00
4770d11e39 feat: Add physical controller button shortcuts (#1202)
* feat(controller): add physical button shortcuts

* fix(controller): improve shortcut capture and assignment feedback

* refactor(controller): simplify menu button assignment UI

* feat(controller): save menu assignments on button release

* refactor(controller): remove obsolete shortcut UI resources

* fix(controller): place drawer remaps below Back

* refactor(controller): simplify shortcut capture wording

* fix(controller): refresh active shortcut bindings on changes

* fix(controller): preserve drawer confirmation for mapped A

* fix: preserve controller shortcut navigation and cancel

* test: align shortcut dialog assertions

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-30 07:08:59 -04:00
MaxsTechReview f69e2d0171 fix: Steam's UI draws at an even share of the refresh near 60 fps instead of the panel's full rate (lag and heat since 0.6.7) (#1809) 2026-10-30 02:16:25 -04:00
MaxsTechReview b0a8c2065d fix: a folder of games on game storage lists each game instead of one combined game (#1808) 2026-10-30 02:01:56 -04:00
MaxsTechReview d4fd7ac5c3 fix: "DroidDeck isn't responding" on dual-screen handhelds when input reaches the bottom screen (#1806) 2026-10-30 01:56:44 -04:00
MaxsTechReview ed661d7007 fix: the performance HUD is off by default and keeps the user's own choice (#1807) 2026-10-30 00:10:06 -04:00
MaxsTechReview 265335d550 fix: the Stop button no longer wraps letter by letter on narrow bottom screens (#1805) 2026-10-30 00:09:53 -04:00
MaxsTechReview 2b0d633535 fix: the Duo's bottom screen keeps the panel you picked across games (#1803) 2026-10-29 23:45:06 -04:00
MaxsTechReview 3b917c0db3 fix(runtime): ship GTK 3 and the libraries Proton's arm64 builds need (#1802) 2026-10-29 23:44:50 -04:00
MaxsTechReview 58968c9286 fix: itch.io installs unpack a game archive nested inside the download (#1800) 2026-10-29 23:21:38 -04:00
Kurt Himebauch 5736297987 fix: keep the launcher from reclaiming focus when an app opens on the primary screen (#1797) 2026-10-29 21:58:23 -04:00
MaxsTechReview 26e2f26ec9 fix: Steam's Browse buttons work when another app holds port 8080 with Decky enabled (#1798) 2026-10-29 21:22:14 -04:00
MaxsTechReview 23bb361006 fix: Steam no longer restarts every launch to import added games, and asks first (#1795) 2026-10-29 21:14:11 -04:00
MaxsTechReview ee3876b965 Windows components: restore bundle parts, wait for restores and keep older picks (#1788) 2026-10-29 20:31:18 -04:00
Kurt Himebauch 5d6f9c7d74 feat: show the DroidDeck D mark as the fastfetch logo (#1778) 2026-10-29 20:21:49 -04:00
MaxsTechReview d5d3aa2ae4 fix: ARMSX2, melonDS and Cemu start without the desktop installed (#1793) 2026-10-29 20:16:55 -04:00
MaxsTechReview 31c943c82a fix: Windows installs to Android storage no longer ask for an administrator (#1791) 2026-10-29 19:12:22 -04:00
MaxsTechReview 3b9abf6302 Update the Linux runtime by itself when a new one is out (#1787) 2026-10-29 18:50:26 -04:00
MaxsTechReview 161a93a4b3 Session follows the Android time zone, and Teardown keeps its own vsync (#1790) 2026-10-29 18:49:11 -04:00
Kurt Himebauch 7d3d338fb9 Keep the manual second screen option when Dual screen is Off (#1777) 2026-10-29 18:39:17 -04:00
MaxsTechReview a3da6f0bda Launch frontend game links without a tap on dual-screen devices (#1789) 2026-10-29 18:37:52 -04:00
MaxsTechReview b726e19fa4 Keep Windows components across runtime updates and reinstall lost ones (#1786) 2026-10-29 17:48:04 -04:00
Kurt Himebauch 001ec02a17 chore: bump version to 0.6.7 (16) (#1783) 2026-10-29 15:20:25 -04:00
Kurt Himebauch a69ae77fc5 docs: DroidDeck 0.6.7 release notes (#1765) 2026-10-29 15:18:55 -04:00
MaxsTechReview 22d39998f0 fix(desktop): let desktop apps save read-only game files, and make game libraries writable (#1781) 2026-10-29 15:16:17 -04:00
MaxsTechReview 5786e89ff4 feat(desktop): one bottom panel with the system tray instead of a top bar (#1772) 2026-10-29 12:32:38 -04:00
Kurt Himebauch e892805c2f fix: ship libxcb-keysyms1 in the Ubuntu runtime so DD-Turnip loads (#1773) 2026-10-29 12:22:25 -04:00
MaxsTechReview 0258f10df0 fix(desktop): start Electron and Chromium apps from apt without the sandbox, on X11 (#1775) 2026-10-29 12:02:40 -04:00
Kurt Himebauch 30923635af fix: remove performance HUD detail text (#1768) 2026-10-29 10:37:25 -04:00
The412Banner 15b00fc23c fix(release): rename the dual-screen and typing task affinities for variant APKs (#1767) 2026-10-29 10:12:02 -04:00
MaxsTechReview 9f7f6d821d perf(epic): keep the hidden launcher from loading its store and Fab pages, and compress it while games run (#1764)
* perf(epic): keep the hidden launcher from loading its store and Fab pages, and compress it while games run

The hidden Epic Games Launcher that stays up for sign-in while a game runs pre-warmed its store and Fab pages on a display nobody sees. Under FEX each page used about one core and grew past a gigabyte, crashing and reloading in a loop. The hidden instance now skips the store pre-warm, gets a blank page in place of Fab, and is paged out to zRAM with the Steam client once a game has focus. The visible launcher is unchanged.

* test(zram): keep the scan fixture PIDs above the kernel PID range
2026-10-29 10:01:25 -04:00
MaxsTechReview 29c83976d0 Move the Linux runtime to Ubuntu 26.04 LTS with a separate Kubuntu desktop (#1733) 2026-10-29 09:05:20 -04:00
The412Banner 5de690ef39 fix: FusionHUD Pill battery row, and a locked HUD lets on-screen controls work (#1763)
Pill: battery level, temperature and watts share one "BAT" row, as on the
other sizes, and each part follows its own switch. Turning Memory off no
longer hides the battery temperature (reported by Keegan), and watts show
without the battery level.

Touch (part of #1752, reported by ZZCSK): a locked HUD lets a touch through
where an on-screen control under it takes it: controller buttons and sticks,
Steam touch controls, screen keys and the controls panel below the game.
Each layer answers takesTouchAt with its own touch checks. Everywhere else
the HUD keeps the touch, so a long press still locks or unlocks it. A new
HUD starts locked. The "Lock in place" hint says so in every language.

Tests: Pill battery row per switch combination; takesTouchAt agrees with a
real touch over a grid for the controller buttons, screen keys and both
controls panel layouts (Deck and gamepad, landscape and portrait); a locked
HUD passes touches to a control under it, keeps the rest, and unlocks on a
long press.
2026-10-29 12:51:28 +00:00
MaxsTechReview 283e3b4549 Use the second screen on dual-screen handhelds (#1734)
* Use the second screen on dual-screen handhelds

Detect built-in second screens such as the Retroid Pocket Duo and AYN Thor.
The sections move to a menu on the bottom screen, L2 and R2 switch sections,
and text fields type on the bottom screen so the top keeps the full view.
Sessions open the bottom-screen trackpad and keyboard when they start.
Setup gains a Dual screen choice (Automatic, On, Off) with toggles for the
bottom menu and the in-game controls. Closing the bottom menu no longer ends
a running session.

* Type into Steam from the bottom screen

While the bottom-screen trackpad and keyboard controls are open in a Steam
session, Steam's own on-screen keyboard no longer covers the top screen.
The agent keeps Steam's text target and asks the app instead, which opens
the Android keyboard on the bottom screen and hands focus back to the game
when it closes. Modal and VR keyboards, and sessions without the bottom
controls, keep Steam's keyboard. The Show keyboard button now follows the
keyboard when it is closed another way.

* Open the bottom controls after the first frame and tidy dual-screen translations

* Move the in-game menu to the bottom screen on dual-screen handhelds

Fix controller input and focus on the second screen: keys and sticks reach the game after touching the bottom controls, closing the bottom controls returns focus to the game, B closes bottom typing, and the typing panel keeps a focusable window until focus has left it.

* Let B close the open layer of the session menu first and focus the selected bottom-screen mode

* Let the Retroid Pocket Duo Home double press open its quick menu during sessions

* Open the keyboard on the bottom screen for Steam text fields and hide it on Enter or when the field loses focus

* Keep controller focus on the top screen when a section is picked on the bottom screen

* Keep LB and RB on the store tabs and let Back return from a store to its tab

* Show the Adreno 722 as supported on the Retroid Pocket Duo

* Label the Steam integration suggestion's close button Close and cover that closing it is permanent

* Close the docked menu with its presentation, keep a focus picked during the stop delay, and ignore panel picks on process-settings pages
2026-10-29 07:52:18 -04:00
MaxsTechReview e9e0186e66 perf(fex): answer raw stat, readlink and statx syscalls through the proot fast path (#1757)
* perf(fex): answer raw stat, readlink and statx syscalls through the proot fast path

FEX issues newfstatat, readlinkat and statx as raw syscall() calls, which skipped the libc wrappers the fast path hooks and fell through to ptrace. Route them through libblfastpath the same way raw openat already is, and keep the fast path's own /proc/self/exe lookup off the hooked syscall().

* test(fex): keep the raw lookup test passing on kernels without statx
2026-10-29 07:25:39 -04:00
Kurt Himebauch 8329033121 feat: setting to hide sidebar labels (#1749) 2026-10-29 07:19:40 -04:00
Kurt Himebauch 81a6186f9e fix: smooth grid scrolling when focus moves past the visible rows (#1750) 2026-10-29 07:01:32 -04:00
MaxsTechReview d1bd23df16 feat: warn when a game library drive is linked at USB 2.0 speed (#1746)
* Warn when a drive used as a game library is linked at USB 2.0 speed

Reads the drive's USB link speed from sysfs through its mount, and shows a
dismissible warning with the USB generation, link speed and a tip for a
faster cable or drive. The warning returns after the drive is plugged in
again unless Don't show this again is switched on.

* Keep slow USB dismissals per plug-in across restarts and recheck after a dismiss

Dismissals are stored with the boot count and the USB bus and device number,
so they survive a process restart, end when that drive is plugged in again
even while the launcher is stopped, and never clear other drives. A dismiss
looks for the next slow drive, stale scans are dropped, and an unknown port
speed is no longer treated as a USB 2.0 port.

* Drop slow USB scans that a newer library or mount check has superseded

* Invalidate pending slow USB scans as soon as a media broadcast arrives

* Revalidate an open slow USB warning on every check and match raw mount library paths

Every check now rescans, so a warning that is already showing is replaced or
cleared when its drive is removed or the library changes. Library paths saved
under /mnt/media_rw are treated as their /storage volume.

* Keep the Don't show this again choice when a rescan closes the slow USB warning

* Save the slow USB opt-out before the launcher activity is torn down
2026-10-29 00:18:07 -04:00
Kurt Himebauch 2410b8c67c feat: art-first store cards (#1747)
* feat: art-first store cards that share the library display settings

* fix: keep price and installed state on store cards when size is hidden

* fix: darken the compact store badge for contrast on light artwork
2026-10-28 23:47:00 -04:00
Kurt Himebauch 64950099a3 fix(storage): stack the summary card in narrow panes (#1736) 2026-10-28 23:08:59 -04:00
Kurt Himebauch 0516d023fa feat: art-first library cards with a display menu (#1744)
* feat: art-first library cards with a display menu for size, title and storage

* build: use native arm64 docker on Apple Silicon and add a fast deploy loop

* fix: address review feedback on library cards and local build scripts
2026-10-28 22:40:53 -04:00
The412Banner 110cfc28f8 feat: FusionHUD replaces the session HUD, with MangoHud's readings (#1743)
The session HUD is FusionHUD (Bannerlator's HUD) with every reading taken from
the same source, and worked out the same way, as the MangoHud build that runs
Steam's performance overlay in a DroidDeck session.

- gamescope patch 0125 (gamescope-3.16.29-p19) shares the frame times that
  mangoapp reads (one per signalled commit of connector 0) in a ring named by
  DROIDDECK_HUD_FRAMES; the app reads it once per display frame and counts the
  compositor's frames when gamescope sends none.
- MangoHud's rules: FPS over periods of at least 500 ms, frame time = 1000/FPS,
  200-frame graph on a fixed 0-50 ms scale; CPU, GPU, temperatures, RAM, swap,
  VRAM and battery from MangoHud's sources. Zones 22/26/28 are typed as GPU and
  DDR sensors, so Steam's overlay CPU temperature no longer includes them.
- Five sizes in a panel shaped like the DroidDeck mark, with a centred header,
  one left edge, neon outline and FPS; Minimal is the default size. Settings on
  a new "Performance HUD" drawer page, with Bannerlator's options.
- The HUD reads sensors and polls frames only while it is drawn (not while
  hidden, in picture-in-picture or behind another app), keeps its place after
  picture-in-picture, and the diagnostics export has its own reader.

Device-tested on the AYANEO Pocket FIT: same readings as Steam's overlay side
by side in Dead Cells; the HUD updates in a game, hides in picture-in-picture
and comes back in place, no crashes.
2026-10-29 01:53:45 +00:00
devSharaku e871557bf2 Update Xbox backend to 0.2.12 and accept compact JWE tickets (#1729)
Some Microsoft accounts get a delegation-compact (d=) ticket, which the
pinned Xbox backend (Nexus 0.2.8) rejected: the library stayed empty
(store-ticket-format) and downloads failed. This moves the backend to
Nexus 0.2.12, which has the upstream store-ticket fix, rebases the
DroidDeck credentials, launch-view and cloud-token patches, and strips
the d= prefix from compact JWE tickets so downloads work again.

Tested by seven users in the Discord Xbox Store Issues thread on the
pr-1729 build at d55e05a0.

* Update Xbox backend to 0.2.12
* Fix cloud offering fallback and add regression tests
* Fix Xbox authentication with compact JWE tickets
2026-10-29 00:07:53 +00:00
The412Banner a8b3c2d5dc docs(license): name DroidDeck in the GPL notice instead of Pluvia (#1741)
The notice at the end of LICENSE still carried Pluvia's name and
copyright line from GameNative's copy of the file.
2026-10-28 23:56:12 +00:00
Kurt Himebauch 87572a7f91 feat: game page with art backdrop and frosted controls (#1738) 2026-10-28 19:33:33 -04:00
Kurt Himebauch 1e92d10fa5 fix(ui): keep the ring veil count per test (#1739)
* ci: run unit tests in four forks

* fix(ui): keep the ring veil count per test so tests do not share snapshot state

* ci: keep two unit test forks, four were slower
2026-10-28 18:19:15 -04:00
Kurt Himebauch 92787dd0f0 ci: run unit tests in two parallel forks (#1737)
* ci: run unit tests in two forks

* test: start spatial controller mode tests with glasses output selected
2026-10-28 17:35:11 -04:00
Kurt Himebauch aef1f85aa9 ci: run store engine host tests in a parallel job (#1735)
* ci: run store engine host tests in a parallel non-gating job

* ci: harden the store tests job permissions, timeout and exit status
2026-10-28 16:47:42 -04:00
Kurt Himebauch 198f667bf0 feat: add GOG maintenance 2026-10-28 16:10:17 -04:00
Kurt Himebauch 52fa41767c fix: stop stale Android readbacks snapping Steam's brightness slider back (#1731)
* fix: stop stale Android readbacks snapping Steam's brightness slider back during a held stick

* fix: let deploy_fast sign with the release env on macOS and Linux

* fix: honor DROIDDECK_SIGNING_ENV first and cover the env signer in tests

* fix: honor DROIDDECK_SIGNING_ENV first and cover the env signer in tests

* fix: retry held brightness readbacks, use a monotonic hold clock and resolve the signing env path

* fix: resolve the build-tools path before signing changes directory

* fix: pick the newest build-tools numerically and keep the env file from replacing them

* fix: only pick build-tools that have aapt, zipalign and apksigner
2026-10-28 15:25:48 -04:00
Kurt Himebauch 6469a7693c ci: check out without history blobs or the release-note GIFs (#1725) 2026-10-28 13:11:14 -04:00
Kurt Himebauch cd1caf523d Default to Native background behavior and automatic PiP (#1727) 2026-10-28 13:10:08 -04:00
Kurt Himebauch 37e0f30cc6 Reset a game's Wine prefix from Game data (#1724) 2026-10-28 13:09:47 -04:00
Kurt Himebauch 23811147aa Guide the child-process toggle when developer options are off (#1726)
* feat(setup): guide build number and the child-process toggle when developer options are off

* fix(setup): only guide the child-process toggle on devices that have it
2026-10-28 13:01:44 -04:00
633 changed files with 46375 additions and 1870 deletions

No files matched your search

+9 -5
View File
@@ -60,11 +60,12 @@ jobs:
- name: Build
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv gcc-aarch64-linux-gnu >/dev/null
KDE_KWIN_OVERRIDE="$PWD/out/kwin" DESKTOP_KDE_RELEASE_TAG='${{ inputs.tag }}' tools/desktop-kde/build.sh out
cp out/kwin/desktop-kde.kwin.txt out/
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
echo "pacman repository $(ls out/repo | wc -l) files, $(du -sm out/repo | cut -f1) MiB"
- uses: actions/upload-artifact@v4
with:
name: desktop-kde
@@ -77,6 +78,7 @@ jobs:
out/desktop-kde.sha256
out/desktop-kde.i18n.txt
out/desktop-kde-i18n-*.tar.zst
out/repo/
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
@@ -89,8 +91,10 @@ jobs:
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
exit 1
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
prerelease=false
case $TAG in *-rc*) prerelease=true ;; esac
gh release create "$TAG" --latest=false --prerelease="$prerelease" --title "$TAG" \
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
gh release edit "$TAG" --latest=false --prerelease=false
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst out/repo/*
gh release edit "$TAG" --latest=false --prerelease="$prerelease"
@@ -0,0 +1,95 @@
name: Build Linux runtime (Ubuntu)
on:
push:
paths:
- 'tools/ubuntu/**'
- '.github/workflows/build-ubuntu-runtime.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish as (e.g. ubuntu-r1); empty = artifact only'
required: false
default: ''
permissions:
contents: read
jobs:
debs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build Mesa with KGSL and the patched KWin
run: |
set -euo pipefail
docker run --rm -v "$PWD:/work" -w /work ubuntu:26.04 bash tools/ubuntu/build-debs.sh /work/out/debs
sudo chown -R "$(id -u):$(id -g)" out
- uses: actions/upload-artifact@v4
with:
name: ubuntu-runtime-debs
retention-days: 7
compression-level: 0
path: out/debs/
rootfs:
needs: debs
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: ubuntu-runtime-debs
path: out/debs
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Refuse a published tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
run: |
if gh release view '${{ inputs.tag }}' -R Droid-Deck/DroidDeck-Components >/dev/null 2>&1; then
echo "::error::${{ inputs.tag }} is already published, and published tags are never replaced: use a new tag"
exit 1
fi
- name: Build
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y -qq zstd gpgv ubuntu-keyring >/dev/null
tag='${{ inputs.tag }}'
tools/ubuntu/build.sh out/debs out "${tag:-ubuntu-dev}"
- uses: actions/upload-artifact@v4
with:
name: ubuntu-runtime
retention-days: 7
compression-level: 0
path: |
out/*.tar.zst
out/*.sha256
out/*.packages.txt
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
GH_REPO: Droid-Deck/DroidDeck-Components
run: |
set -euo pipefail
TAG='${{ inputs.tag }}'
prerelease=false
case $TAG in *-rc*) prerelease=true ;; esac
gh release create "$TAG" --latest=false --prerelease="$prerelease" --title "$TAG" \
--notes "Ubuntu 26.04 LTS arm64 runtime for DroidDeck ($GITHUB_REPOSITORY tools/ubuntu, $GITHUB_SHA): size $(stat -c %s "out/$TAG.tar.zst"), sha256 $(cat "out/$TAG.sha256"). $TAG-desktop.tar.zst is the Kubuntu Plasma desktop built on top of it, holding only what the runtime does not already have: size $(stat -c %s "out/$TAG-desktop.tar.zst"), sha256 $(cat "out/$TAG-desktop.sha256"). The .packages.txt files list every installed package and version." \
"out/$TAG.tar.zst" "out/$TAG.sha256" "out/$TAG.packages.txt" \
"out/$TAG-desktop.tar.zst" "out/$TAG-desktop.sha256" "out/$TAG-desktop.packages.txt" out/debs/*.deb
gh release edit "$TAG" --latest=false --prerelease="$prerelease"
+80 -8
View File
@@ -49,6 +49,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Pin and merge the APK source
id: merge
@@ -82,6 +86,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
@@ -136,6 +144,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
@@ -287,6 +299,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check human authorship
@@ -417,6 +433,7 @@ jobs:
id: build-label
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BUILD_REF: ${{ github.ref_name }}
BUILD_SHA: ${{ github.sha }}
@@ -435,6 +452,7 @@ jobs:
echo "label=$label" >> "$GITHUB_OUTPUT"
echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
echo "DROIDDECK_PR_TITLE=${PR_TITLE//$'\n'/ }" >> "$GITHUB_ENV"
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
- name: Rust toolchain for the store engines (aarch64-linux-android)
@@ -530,13 +548,6 @@ jobs:
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
- name: Store engines host unit tests
timeout-minutes: 10
continue-on-error: true
run: |
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
- name: Upload the regenerated Cargo.lock
if: failure()
uses: actions/upload-artifact@v4
@@ -626,6 +637,63 @@ jobs:
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
run: bash tools/release/report-build.sh
store-tests:
name: Store engines host tests
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 15
continue-on-error: true
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -euo pipefail
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "[email protected]"
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match
env:
XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- name: Rust toolchain
id: rust
run: echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache cargo registry and the host test target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
app/src/main/rust/stores/target
key: stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs') }}
restore-keys: |
stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-
stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-
- name: Store engines host unit tests
timeout-minutes: 10
continue-on-error: true
run: |
set -o pipefail
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
python-tests:
name: Python and native regression tests
needs: source
@@ -637,6 +705,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
@@ -690,7 +762,7 @@ jobs:
checks:
name: Compile and test APK
needs: [build, python-tests]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
for name in proot directaudio tracking xbox comet butler; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
+3 -3
View File
@@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Pluvia is an unofficial Steam client app for Android
Copyright (C) 2024 Oxters Wyzgowski
DroidDeck brings the SteamOS experience to Android
Copyright (C) 2026 DroidDeck contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Pluvia Copyright (C) 2024 Oxters Wyzgowski
DroidDeck Copyright (C) 2026 DroidDeck contributors
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
+9 -1
View File
@@ -33,6 +33,14 @@ Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adre
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.
## Physical button shortcuts
Open the launcher's **Controls** settings, **Setup → Controller → Button mapping**, or the session drawer's **Controller** page. Each menu action has one row showing **Custom** or **Default**. Select the current value beside **Steam menu**, **Open Quick Access Menu**, or **DroidDeck menu**, then press and release a physical button. The assignment saves automatically on release; confirmation is only required if the button already opens another menu. Assignments are stored per controller; each action has one assigned button per controller. A mapped button performs the shortcut instead of sending its normal input to the game. Steam shortcuts only apply in Steam sessions, and shortcuts are inactive while loading, suspended, or using the on-screen keyboard or other input dialogs.
Unassigned buttons keep their existing behavior, including the Back button's single/double-press setting and Select-to-Steam option. While the session drawer is open, A selects the focused item even if it has a shortcut assignment. Select an action's current value and use **Reset** beside an assignment to restore that behavior without changing touch layouts or other controller settings. **Close** exits capture without changing an assignment; it does not undo an assignment already applied. The touchscreen remains available for configuration and recovery.
Extra buttons such as M1/M2 work only if Android exposes distinct key events to the app. Android Home, volume and other reserved system keys cannot be assigned; making DroidDeck the default launcher retains its existing Home-to-Steam behavior. Firmware that intercepts a button or makes it duplicate another button cannot be overridden here. During capture, the detected device and button name are shown. If a controller disconnects before release, use **Try another button** to restart capture; **Reset** remains available for existing assignments. Capture and shortcut-action diagnostics, including key and scan codes, are included in session logs when capture runs inside a session. If a button is not detected, record its physical label, firmware/controller mode, and whether it leaves the app, then share the session logs.
## Smart glasses
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
@@ -61,7 +69,7 @@ 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. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. Its games install from Stores through Blizzard's own Agent and the local HTTP API it serves, driven the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) drives it, with the requests described on [wowdev.wiki](https://wowdev.wiki/Agent); how other launchers find, install and start Blizzard games was read in [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader, without taking their code. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. 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, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. x86 AppImages packed with [sharun](https://github.com/VHSgunzo/sharun) by VHSgunzo (MIT) start because DroidDeck's own [FEX](https://github.com/FEX-Emu/FEX) build (MIT) puts the platform name under `AT_PLATFORM`, which sharun's [userland-execve](https://crates.io/crates/userland-execve) loader (MIT) requires; FEX filed it under `AT_BASE_PLATFORM`. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultLine truncated
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. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. Its games install from Stores through Blizzard's own Agent and the local HTTP API it serves, driven the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) drives it, with the requests described on [wowdev.wiki](https://wowdev.wiki/Agent); how other launchers find, install and start Blizzard games was read in [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader, without taking their code. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. 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, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. x86 AppImages packed with [sharun](https://github.com/VHSgunzo/sharun) by VHSgunzo (MIT) start because DroidDeck's own [FEX](https://github.com/FEX-Emu/FEX) build (MIT) puts the platform name under `AT_PLATFORM`, which sharun's [userland-execve](https://crates.io/crates/userland-execve) loader (MIT) requires; FEX filed it under `AT_BASE_PLATFORM`. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. The in-game performance HUD is [@The412Banner](https://github.com/The412Banner)'s [FusionHUD](https://github.com/The412Banner/FusionHUD) (GPL-3.0 with an attribution term), Bannerlator's HUD; its frame rate, frame times and readings are taken from the same sources, and worked out the same way, as Valve's [MangoHud](https://github.com/flightlessmango/MangoHud) build (MIT) that runs Steam's performance overlay in a DroidDeck session, with the frame times shared by gamescope patch 0125; when gamescope sends no frames for two seconds, the HUD counts the frames DroidDeck's compositor shows instead, which stop at the screen's refresh rate. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and isLine truncated
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
+21 -3
View File
@@ -78,6 +78,10 @@ def escapedBuildLabel = buildLabel.replace('\\', '\\\\').replace('"', '\\"')
def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getOrNull()
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
def prTitle = providers.environmentVariable('DROIDDECK_PR_TITLE').getOrNull() ?: ''
def buildBranch = providers.environmentVariable('GITHUB_HEAD_REF').getOrNull()?.trim()
?: gitValue(['branch', '--show-current']) ?: ''
def escapeJava = { String value -> value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ').replace('\r', ' ') }
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
@@ -90,7 +94,7 @@ def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLine
def prootPackPin = rootProject.file('tools/packs/proot/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
@@ -130,11 +134,13 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 15
versionName "0.6.0"
versionCode 16
versionName "0.6.7"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
buildConfigField "String", "PR_TITLE", "\"${escapeJava(prTitle)}\""
buildConfigField "String", "BUILD_BRANCH", "\"${escapeJava(buildBranch)}\""
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
@@ -234,6 +240,7 @@ android {
}
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
maxParallelForks = 2
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
@@ -286,6 +293,13 @@ if (uvcDir) {
}
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
def stageGogNotices = tasks.register('stageGogNotices', Copy) {
from(layout.projectDirectory.dir('src/main/rust/stores/vendor/xdelta3')) { include 'LICENSE', 'README.droiddeck.md' }
into(layout.buildDirectory.dir('gogNotices/licenses/xdelta3'))
}
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices'))
tasks.named('preBuild').configure { dependsOn(stageGogNotices) }
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
workingDir rootProject.projectDir
@@ -303,6 +317,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
onlyIf { !skipRust }
inputs.dir(rustCrateDir.dir('src'))
inputs.dir(rustCrateDir.dir('vendor'))
inputs.file(rustCrateDir.file('build.rs'))
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
inputs.property('ndkVersion', android.ndkVersion)
if (!skipRust) inputs.property('rustToolchain', providers.exec {
@@ -331,6 +347,8 @@ dependencies {
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'
// dx (Apache-2.0) turns the Samsung performance SDK a user imports into dex on the phone (SamsungPerf).
implementation 'com.jakewharton.android.repackaged:dalvik-dx:16.0.1'
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
+27
View File
@@ -11,6 +11,7 @@
</intent>
</queries>
<uses-permission android:name="android.permission.REORDER_TASKS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -68,6 +69,12 @@
</intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
</service>
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
android:process=":shortcuts_sync">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<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"
@@ -243,6 +250,26 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.DualScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity="${applicationId}.dualscreen"
android:theme="@style/SessionTheme"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.SessionTypingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity="${applicationId}.typing"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen"
android:resizeableActivity="true"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".gpu.SteamVrRestartActivity"
android:exported="false"
@@ -3,31 +3,52 @@
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164",
"desktop-kde-r3": "8b7142fcec8d86143ae39fc14ffc86907d30d9a5b5a45f49cb1b55109d6fc4eb"
},
"desktopApplications": {
"ark-26.08.1-1-aarch64.pkg.tar.xz": "93e2c2b75ff20cd29b4f312ed1979ef6f27255b93232b93bfcdb31239903c1d4",
"breeze-6.7.5-1-aarch64.pkg.tar.xz": "1d7f4eb9d8d234caaea23621909f5233d19462c2f058afeeb54bed7b81bd03bd",
"breeze-gtk-6.7.5-1-any.pkg.tar.xz": "a0f59bc4fc426879c8f8a97f6dcc707fb7d1b52913da5eb7e9db83777b6957b7",
"breeze-icons-6.30.0-1-aarch64.pkg.tar.xz": "4bae4689032501156687a227024e56554820747644953c67fc8d2e2b6b0b045a",
"dolphin-26.08.1-1-aarch64.pkg.tar.xz": "fef738ab8132d3eba1156b98c116699ce0a5efcb4cb462efbacdcf4c47f79a23",
"firefox-157.0.1-1-aarch64.pkg.tar.xz": "d142fdb8a339a65eca7cea6a2bba5b29202386166abd9d59851d6757c9eb2163",
"gwenview-26.08.1-1-aarch64.pkg.tar.xz": "a9dc7d5ab73fa677905d5a57248b1b9b404b6b16e862958cc7443d387f684a47",
"kate-26.08.1-1-aarch64.pkg.tar.xz": "acf50710042746f243a3b219a1ae0f788e338f3dd9d75093e8bb671a618b83b6",
"kde-cli-tools-6.7.5-1-aarch64.pkg.tar.xz": "af466398a912a994a97fadfe46aff401d88a90d7fdce7e843dc4218098fa38ac",
"kde-gtk-config-6.7.5-1-aarch64.pkg.tar.xz": "ba6cb4cb01dbb3f2c247f2f6973ede3b64c0849f89340217b51dfbf11cb8cb5c",
"kinfocenter-6.7.5-1-aarch64.pkg.tar.xz": "f3d5416db96b50f9a5b8a284d82ae21d834a8cce646010abdd7087491c399b0b",
"konsole-26.08.1-1-aarch64.pkg.tar.xz": "1ecf87c0d11d620ebe4aeafb02788e0d5f4fe9019ad9c5d9f1011c43237dd5ec",
"kscreen-6.7.5-1-aarch64.pkg.tar.xz": "8fbaa6fedf93edeba021a5416b551663654919b27c2ee237d696ec0b0ce7d5b7",
"kwin-6.7.5-1-aarch64.pkg.tar.xz": "a7535710c74b90359f80306134b2b7581132cbac508dc84b7e07ef275cc8c3fe",
"7zip-26.05-1-aarch64.pkg.tar.xz": "17569bafbc5bd1a044ca1db164b96cb15857a22a3575f36679de46e693ae285e",
"ark-26.08.2-1-aarch64.pkg.tar.xz": "b93cc90c4fc8a6cb0247fe8fb0f9de1c2074f5173fa2735d3d1959c9162c08f2",
"breeze-6.8.1-1-aarch64.pkg.tar.xz": "70a2ff3afd41602f184814af5c6e298ff84ef7cb7e5880ca7cb4f6fb28521511",
"breeze-gtk-6.8.1-1-any.pkg.tar.xz": "e92b9acf3155db22d4a288a43e8d95c20c72b8d8cffd028dd6800eb01a304bf0",
"breeze-icons-6.31.0-1-aarch64.pkg.tar.xz": "b252ef02395621b5891d72c5f7fccd1a1ead1ce770886e9fced4bf21e1130096",
"discover-6.8.1-1-aarch64.pkg.tar.xz": "64cfca615e7c18530c684325eb36a5c45e18c63b31f651335474f53b0178958c",
"dolphin-26.08.2-1-aarch64.pkg.tar.xz": "4b5f7a9212c88d47146b374742f67f5620ab128ad893d945721bb43501066b70",
"elisa-26.08.2-1-aarch64.pkg.tar.xz": "fd2df84a2905b59fba0ab1b7fadafe567c5a1dfd44d53ae4cd4996f683b83ba2",
"filelight-26.08.2-1-aarch64.pkg.tar.xz": "17f39435104baf0743ab508bdde6e8bd1b3417b025b67ca8f343032d2fdef3e6",
"firefox-158.0.1-1-aarch64.pkg.tar.xz": "4940e14c05522bfee8df901e1cfe81f56036c82e8f3d95f9f7a2a12e6b38d232",
"flatpak-kcm-6.8.1-1-aarch64.pkg.tar.xz": "3145601002fb87d61307949a3011a8f27a7b1e9851fc59835460f934f8e00098",
"git-2.56.0-1-aarch64.pkg.tar.xz": "b152a20edb215e98cada4b2dfbd02a9e925bda0b2de6ac1441e9e60b6698d3b5",
"gwenview-26.08.2-1-aarch64.pkg.tar.xz": "02aae3973ec0563022c89e312ce7696d05d33514101ae2a01df3275717929de2",
"htop-3.5.3-1-aarch64.pkg.tar.xz": "a58899bd2596d55575493509b3a5aa6f53075cb3d88bb6c712088c57068d9eb7",
"kate-26.08.2-1-aarch64.pkg.tar.xz": "89c21ad8e62d20961b13f8fac8d9f4d358b1a3feebed243f6412e4ad996d5e9e",
"kcalc-26.08.2-1-aarch64.pkg.tar.xz": "79b3303ccc5ae956e97313439456243e59f0b79a8166ca91606a25d1c9e41029",
"kde-cli-tools-6.8.1-1-aarch64.pkg.tar.xz": "b052e9c25e7d6896be67846696f14c9e00430c0445f358ee64cf2a8ea16aa2eb",
"kde-gtk-config-6.8.1-1-aarch64.pkg.tar.xz": "3095da034ac5fe8594165e3396f61f083b0c011cd96807146ddfb53690118608",
"kfind-26.08.2-1-aarch64.pkg.tar.xz": "b4108cc3bd9efd62970379aadb3cb22e99dd1f54873ff432392edd0a833bb393",
"kinfocenter-6.8.1-1-aarch64.pkg.tar.xz": "758882ef13a166205acab0e6bb96ce756d638d91e501db400cb579b9e1331d31",
"konsole-26.08.2-1-aarch64.pkg.tar.xz": "0cf27f69d1792a094d4a935f578c82f8151b2d359198d9cd32bfdbd8dec544e4",
"kscreen-6.8.1-1-aarch64.pkg.tar.xz": "72c948dfdebfef305623527de27274baf1a2328c87e71033fd852e386d9fdf5c",
"kwin-6.8.1-2-aarch64.pkg.tar.xz": "726c98a3f8682b487c2b313633edbd80aa8b20ff657a6b63bbb1b73454f6e0c4",
"less-1:710-1-aarch64.pkg.tar.xz": "8e1d4cbecf6d2ae734cfc1c5495367c0a9245f92dfd2bc03d4d0ab14f8b26ff2",
"man-db-2.13.1-2-aarch64.pkg.tar.xz": "67515751f848717e1f6d3470f57ad2ea6900ee07618fd2726b6fcaa695687e80",
"man-pages-6.19-1-any.pkg.tar.xz": "69df38f1a1aa35240d239ff82a2e067950ac394f23b8a6c7307580ccd7e95f07",
"mpv-1:0.41.0-6-aarch64.pkg.tar.xz": "6c89d00736820210a3c7f32af4cf95d155191d463f9df8bd22137c45f30a554a",
"plasma-desktop-6.7.5-1-aarch64.pkg.tar.xz": "b266dd42cea965b5ab1cc1ae600877e9d3bc83ec642c61fe1669c4457e641a55",
"plasma-integration-6.7.5-1-aarch64.pkg.tar.xz": "daa96da14d9a4d12c0d9c0341e1be6836a9a437c469d61da5e9844d51e2606cf",
"plasma-pa-6.7.5-1-aarch64.pkg.tar.xz": "02dbefe9d3e0069b2bb37cac2a6be18b8f0a1887c87704a0716e7a9268eadcd8",
"plasma-workspace-6.7.5-1-aarch64.pkg.tar.xz": "3a1455857afaa9ccf342ab6167f0f15794ee9d248b0925398ea6f62f12730a86",
"spectacle-1:6.7.5-1-aarch64.pkg.tar.xz": "0c9837412ea6119edef18bcb0a8fceb74634cb35be0ca78b3e1dd1ff2443866e",
"systemsettings-6.7.5-1-aarch64.pkg.tar.xz": "b02b6a726857d5125c92d1ec95379216c7a3bc1f187a3b46e88c0d056b29ce70",
"xdg-desktop-portal-kde-6.7.5-1-aarch64.pkg.tar.xz": "2c57d17d34d577023eb9cd7b0fca1ba9163549afe60450f6271aa148af099ef2"
"okular-26.08.2-1-aarch64.pkg.tar.xz": "aef87cf74730d2345092184d7e9b14a0068ba4030de58564aa770506175cbf06",
"plasma-desktop-6.8.1-1-aarch64.pkg.tar.xz": "4b24662a2059b21ca98b5b35b72216f22c9e316057247ea018bf6318b07cfdb6",
"plasma-integration-6.8.1-1-aarch64.pkg.tar.xz": "514f7d2a96370814b9374d7c52e1873257af6e18190d130e5a8d3d6d1fffdc54",
"plasma-pa-6.8.1-1-aarch64.pkg.tar.xz": "13dff66d30072744c7870bd1a294ae96f0fee086a1533b8052d7dfc038d57c45",
"plasma-systemmonitor-6.8.1-1-aarch64.pkg.tar.xz": "551b7c1f24cd5df64de4f5ba199bc71c00e2924c16a8241b21c020584794e10d",
"plasma-workspace-6.8.1-1-aarch64.pkg.tar.xz": "4c713990aeab118d50865c818f9689ae7637dc6556e4a0e2fec5e0b348cdcd29",
"spectacle-1:6.8.1-1-aarch64.pkg.tar.xz": "0d697803bd0135701688be3869d4d99eec6b8b43ca761610da73f44cb77bfa5c",
"systemsettings-6.8.1-1-aarch64.pkg.tar.xz": "db528089fa328d67dadb50be3b822858238785d8c659a9ef95389b993efcb234",
"unrar-1:7.3.1-1-aarch64.pkg.tar.xz": "9883e19b1f1adfa430285191b1f96db4cc208608baebd955f3fe9fee92cd3bdd",
"unzip-6.0-23-aarch64.pkg.tar.xz": "edc91c7e4e400f20345439744262d1ceded104c78d940659a6b9876076ef33fc",
"wget-1.25.0-6-aarch64.pkg.tar.xz": "e2bf0067cca93877c737c7d48bc36fff32e184c7874829429b9473c10a12dd39",
"wl-clipboard-1:2.3.0-1-aarch64.pkg.tar.xz": "723a63b140e00085a533ebb1ec36fa9305b5e6cbd8fb80c0521fbeff77a72982",
"xclip-0.13-6-aarch64.pkg.tar.xz": "772ec9336b679b0ef277d1ef0b69b74777467239e16ec845af00c07fa849d166",
"xdg-desktop-portal-kde-6.8.1-1-aarch64.pkg.tar.xz": "8098c0ef0454335bb7c806049d66977c512657346222606737d72a7ae1de3146",
"zip-3.0-14-aarch64.pkg.tar.xz": "9887f177544021809d065d0298514e98f76b55f9120119f748f5a28c8bb51994"
},
"legacyApplications": {
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
@@ -1,2 +1,3 @@
desktop-kde-r1
desktop-kde-r2
desktop-kde-r3
+124 -9
View File
@@ -93,6 +93,7 @@ static constexpr unsigned int FAKE_INPUT_MAJOR = 13;
static constexpr unsigned int FAKE_INPUT_EVENT_MINOR_BASE = 64;
static constexpr unsigned int FAKE_INPUT_JS_MINOR_BASE = 0;
static constexpr int UINPUT_EVENT_BASE = 16;
static constexpr int MOUSE_EVENT_SLOT = 4;
static constexpr int UINPUT_EVENT_LIMIT = 64;
static constexpr uint32_t UINPUT_DESCRIPTION_MAGIC = 0x46495544;
@@ -121,6 +122,12 @@ static constexpr size_t FAKE_INPUT_RING_HEADER_SIZE =
static constexpr size_t FAKE_INPUT_RING_SIZE =
FAKE_INPUT_RING_HEADER_SIZE +
(FAKE_INPUT_RING_CAPACITY * FAKE_INPUT_EVENT_SIZE);
static constexpr size_t DECK_IMU_BLOCK_SIZE = 64;
static constexpr size_t FAKE_INPUT_RING_PAD_TYPE_OFFSET = 44;
static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3,
PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE,
PAD_TYPE_MOUSE = 5;
static constexpr uint32_t PAD_TYPE_SET = 0x80000000u;
struct UinputDescription {
uint32_t magic;
@@ -149,6 +156,7 @@ struct FakeController {
size_t mapping_size = 0;
bool closed = false;
bool needs_keyframe = true;
bool mouse = false;
~FakeController() {
if (ring) munmap(ring, mapping_size);
@@ -607,7 +615,7 @@ open_fake_input_ring(const char *event, int flags) {
int slot = get_event_number(event);
bool virtual_pad = slot >= UINPUT_EVENT_BASE;
std::string ring_path = virtual_pad ? uinput_ring_path(slot) : get_ring_path_for_slot(slot);
size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE;
size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE;
if (ring_path.empty()) {
Logger::log("No input ring is configured for %s (slot %d)\n", event, slot);
errno = ENODEV;
@@ -651,9 +659,22 @@ open_fake_input_ring(const char *event, int flags) {
controller->uinput = description;
controller->mapping_size = mapping_size;
controller->generation = ring_generation(ring);
if (!virtual_pad) {
uint32_t pad_type = __atomic_load_n(
reinterpret_cast<volatile uint32_t *>(static_cast<uint8_t *>(mapping) + FAKE_INPUT_RING_SIZE +
FAKE_INPUT_RING_PAD_TYPE_OFFSET),
__ATOMIC_ACQUIRE);
controller->mouse = (pad_type & PAD_TYPE_SET) && (pad_type & ~PAD_TYPE_SET) == PAD_TYPE_MOUSE;
}
SnapshotState snap;
if (read_snapshot(ring, snap) && snap.generation == controller->generation)
capture_keyframe(*controller, snap);
if (read_snapshot(ring, snap) && snap.generation == controller->generation) {
if (controller->mouse) {
controller->read_seq = snap.write_seq;
controller->needs_keyframe = false;
} else {
capture_keyframe(*controller, snap);
}
}
{
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
controller_map()[fd] = controller;
@@ -1156,7 +1177,8 @@ struct DeckImu {
uint32_t pad_type;
uint32_t latency;
};
static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter");
static_assert(offsetof(DeckImu, pad_type) == FAKE_INPUT_RING_PAD_TYPE_OFFSET,
"DeckImu::pad_type matches FakeInputWriter and the ring reader");
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;
@@ -1173,7 +1195,6 @@ struct DeckExtras {
uint32_t controls = 0;
};
static constexpr uint32_t DECK_IMU_MAGIC = 0x31554D49;
static constexpr size_t DECK_IMU_BLOCK_SIZE = 64;
static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes in the ring file");
struct TritonSharedData {
@@ -1270,10 +1291,6 @@ static bool fake_deck_enabled() {
return enabled == 1;
}
static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3,
PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE;
static constexpr uint32_t PAD_TYPE_SET = 0x80000000u;
__attribute__((visibility("hidden"))) static uint32_t session_pad_type() {
static int type = -1;
if (type < 0) {
@@ -1408,6 +1425,12 @@ __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const ch
if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false;
int number = get_event_number(event);
if (number < 0 || number >= UINPUT_EVENT_BASE) return false;
if (number == MOUSE_EVENT_SLOT && !strncmp(event, "event", 5)) {
char *fake = from_real_to_fake_path(pathname);
bool exposed = fake && path_exists(fake);
free(fake);
return !exposed;
}
return number == 0 || !process_is_steam_client();
}
@@ -3351,6 +3374,85 @@ static int copy_ioctl_bits(int op, void *destination, const unsigned char (&bits
return static_cast<int>(size);
}
static constexpr uint16_t MOUSE_VENDOR_ID = 0x1dd1;
static constexpr uint16_t MOUSE_PRODUCT_ID = 0x0004;
static int ioctl_mouse(int op, void *argp) {
int type = (op >> 8 & 0xFF);
int number = (op >> 0 & 0xFF);
if (type != 0x45) {
errno = EINVAL;
return -1;
}
switch (number) {
case 0x01: {
int version = 65536;
memcpy(argp, &version, sizeof(int));
return 0;
}
case 0x02: {
struct input_id id;
memset(&id, 0, sizeof(id));
id.bustype = 0x03;
id.vendor = MOUSE_VENDOR_ID;
id.product = MOUSE_PRODUCT_ID;
id.version = 1;
memcpy(argp, &id, sizeof(id));
return 0;
}
case 0x06:
if (argp && _IOC_SIZE(op)) snprintf(static_cast<char *>(argp), _IOC_SIZE(op), "DroidDeck Mouse");
return 0;
case 0x07:
if (argp && _IOC_SIZE(op)) snprintf(static_cast<char *>(argp), _IOC_SIZE(op), "usb-droiddeck/mouse");
return 0;
case 0x08:
if (argp && _IOC_SIZE(op)) static_cast<char *>(argp)[0] = '\0';
return 0;
case 0x09: {
unsigned char bitmask[(INPUT_PROP_MAX + 8) / 8] = {};
bitmask[INPUT_PROP_POINTER / 8] |= 1 << (INPUT_PROP_POINTER % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x18: {
unsigned char bitmask[(KEY_MAX + 8) / 8] = {};
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x20: {
unsigned char bitmask[(EV_MAX + 8) / 8] = {};
bitmask[EV_SYN / 8] |= 1 << (EV_SYN % 8);
bitmask[EV_KEY / 8] |= 1 << (EV_KEY % 8);
bitmask[EV_REL / 8] |= 1 << (EV_REL % 8);
bitmask[EV_MSC / 8] |= 1 << (EV_MSC % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x21: {
unsigned char bitmask[(KEY_MAX + 8) / 8] = {};
for (int button = BTN_LEFT; button <= BTN_TASK; button++)
if (button <= BTN_MIDDLE || button == BTN_SIDE || button == BTN_EXTRA)
bitmask[button / 8] |= 1 << (button % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x22: {
unsigned char bitmask[(REL_MAX + 8) / 8] = {};
for (uint16_t code : {REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES})
bitmask[code / 8] |= 1 << (code % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x23: {
unsigned char bitmask[(ABS_MAX + 8) / 8] = {};
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x35: {
unsigned char bitmask[(FF_MAX + 8) / 8] = {};
return copy_ioctl_bits(op, argp, bitmask);
}
default:
errno = EINVAL;
return -1;
}
}
static bool wait_snapshot(std::shared_ptr<FakeController> fake,
std::unique_lock<std::recursive_mutex> &guard,
SnapshotState &snap) {
@@ -3417,6 +3519,13 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
int event_number = controller->second->slot;
const UinputDescription *made = controller->second->uinput;
if (controller->second->mouse) {
int result = ioctl_mouse(op, argp);
if (result >= 0) return result;
guard.unlock();
return forward_ioctl(fd, op, argp);
}
if (type == 0x45 && number == 0x1) {
Logger::log("Hooking ioctl EVIOCGVERSION for event %s\n", event);
int version = 65536;
@@ -3680,6 +3789,12 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
if (fake.needs_keyframe || snap.resync_seq != fake.resync_seq ||
snap.write_seq < fake.read_seq ||
snap.write_seq - fake.read_seq > FAKE_INPUT_RING_CAPACITY) {
if (fake.mouse) {
fake.read_seq = snap.write_seq;
fake.resync_seq = snap.resync_seq;
fake.needs_keyframe = false;
continue;
}
capture_keyframe(fake, snap);
} else {
size_t events = std::min<uint64_t>(requested, snap.write_seq - fake.read_seq);
@@ -33,6 +33,8 @@ add_library(droiddeckwayland SHARED
generated/banner-ahb-v1-protocol.c
src/framegen_bridge.c
src/framegen_jni.c
src/hudframes.c
src/hudframes_jni.c
)
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
+112 -10
View File
@@ -503,6 +503,7 @@ struct active_touch { struct surface *surface; int pointer_fallback; };
static struct active_touch g_touch_points[MAX_TOUCH_POINTS];
static void touch_cancel_surface(struct surface *surface);
static struct surface *g_grab;
static void publish_query_state(void);
static struct surface *g_key_target;
static struct surface *g_ime_click;
static int g_input_pipe[2] = {-1, -1};
@@ -596,6 +597,7 @@ static void map_toplevel(struct surface *s) {
raise_kept_on_top();
if (!g_desktop) g_key_target = s;
apply_zorder();
publish_query_state();
}
static void unmap_toplevel(struct surface *s) {
@@ -2122,7 +2124,21 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) {
unsigned droiddeck_stream_frames(void) { return atomic_load(&g_stream_frames); }
static int64_t g_scene_due_ns;
static int scene_held_for_limit(int64_t now, int64_t due, int limit, int unpaced) {
if (limit <= 0 || unpaced || due <= 0) return 0;
return now < due - 1000000LL;
}
static int64_t scene_next_due(int64_t now, int64_t due, int limit) {
if (limit <= 0) return 0;
const int64_t interval = 1000000000LL / limit;
return due > 0 && now - due < interval ? due + interval : now + interval;
}
static void render_scene(void) {
g_scene_due_ns = scene_next_due(now_ns(), g_scene_due_ns, g_fps_limit);
if (atomic_load(&g_stream_on)) { g_dirty_since_ns = 0; render_stream(); return; }
struct draw_list dl = {0};
struct surface *s;
@@ -2300,7 +2316,9 @@ static void render_scene(void) {
static int on_fallback_timer(void *data) {
g_fallback_armed = 0;
if (g_dirty) render_scene();
if (!g_dirty) return 0;
if (scene_held_for_limit(now_ns(), g_scene_due_ns, g_fps_limit, g_release_unpaced)) schedule_render();
else render_scene();
return 0;
}
@@ -2375,6 +2393,7 @@ static void on_vsync(int64_t frame_time_ns) {
g_tick_held = 1;
return;
}
if (g_dirty && scene_held_for_limit(now, g_scene_due_ns, g_fps_limit, g_release_unpaced)) return;
if (g_dirty && !scene_waits_for_game(now)) render_scene();
}
@@ -2432,6 +2451,7 @@ static const struct wl_touch_interface touch_impl = { .release = touch_release }
static void touch_res_destroy(struct wl_resource *r) {
for (int i = 0; i < g_ntouches; i++)
if (g_touches[i].touch == r) { g_touches[i] = g_touches[--g_ntouches]; break; }
publish_query_state();
}
static void seat_get_pointer(struct wl_client *c, struct wl_resource *r, uint32_t id) {
@@ -2469,6 +2489,7 @@ static void seat_get_touch(struct wl_client *c, struct wl_resource *r, uint32_t
g_touches[g_ntouches].focus = NULL;
g_ntouches++;
}
publish_query_state();
}
static void seat_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_seat_interface seat_impl = {
@@ -2584,6 +2605,7 @@ struct relative_pointer {
static struct wl_list g_relative_pointers;
static double g_ptr_x, g_ptr_y;
static int g_ptr_known;
static double g_raw_x, g_raw_y;
static int g_raw_valid;
@@ -2648,6 +2670,7 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
constraint_origin(k, &ox, &oy);
g_ptr_x = ox + k->hint_x;
g_ptr_y = oy + k->hint_y;
g_ptr_known = 1;
}
g_raw_valid = 0;
if (k->gave_keys) {
@@ -2744,6 +2767,7 @@ static void constraints_surface_commit(struct surface *s) {
constraint_origin(k, &ox, &oy);
g_ptr_x = ox + k->hint_x;
g_ptr_y = oy + k->hint_y;
g_ptr_known = 1;
if (log_budget())
droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
}
@@ -2938,6 +2962,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
}
if (k) confine_clamp(k, &x, &y);
g_ptr_x = x; g_ptr_y = y;
g_ptr_known = 1;
if (k) {
target = k->surface;
} else if (g_desktop) {
@@ -2948,6 +2973,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
if (button && !pressed) g_grab = NULL;
}
}
publish_query_state();
if (!target) return;
struct wl_client *client = wl_resource_get_client(target->resource);
@@ -2963,9 +2989,9 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
// may have warped its cursor since the last motion (before taking lock,
// or while releasing it), so that replay can become an unintended pan.
// Absolute touch events still carry their position, including on clicks.
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
int rel_consumed = send_relative_motion(client, dx, dy);
if (!(k && k->is_lock) && !(relative && rel_consumed) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
send_relative_motion(client, dx, dy);
for_each_pointer_of(client, sp) {
if (button)
wl_pointer_send_button(sp->ptr, wl_display_next_serial(g_display), t, button,
@@ -3104,6 +3130,63 @@ static struct surface *touch_target_at(double x, double y) {
return g_grab ? g_grab : g_key_target;
}
static pthread_mutex_t g_query_lock = PTHREAD_MUTEX_INITIALIZER;
struct query_snapshot {
int has_position;
int input_x, input_y;
int focus_takes_touch;
};
static struct query_snapshot g_query = { 0, 0, 0, 1 };
static void publish_query_state(void) {
struct surface *target = g_key_target ? g_key_target : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y));
int takes_touch = 1;
if (target) {
struct wl_client *client = wl_resource_get_client(target->resource);
struct seat_touch *st;
takes_touch = 0;
for_each_touch_of(client, st) takes_touch = 1;
}
int w = 0, h = 0, ow = 0, oh = 0, input_x = 0, input_y = 0, has_position = 0;
scene_size(&w, &h);
vkp_output_size(&ow, &oh);
if (g_ptr_known && w > 0 && h > 0) {
double ox, oy;
if (ow > 0 && oh > 0 && vkp_scene_to_output(g_ptr_x, g_ptr_y, &ox, &oy)) {
input_x = (int)(ox * INPUT_SPACE_W / ow);
input_y = (int)(oy * INPUT_SPACE_H / oh);
} else {
input_x = (int)(g_ptr_x * INPUT_SPACE_W / w);
input_y = (int)(g_ptr_y * INPUT_SPACE_H / h);
}
has_position = 1;
}
pthread_mutex_lock(&g_query_lock);
g_query.focus_takes_touch = takes_touch;
g_query.has_position = has_position;
g_query.input_x = input_x;
g_query.input_y = input_y;
pthread_mutex_unlock(&g_query_lock);
}
int droiddeck_focus_takes_touch(void) {
pthread_mutex_lock(&g_query_lock);
int takes_touch = g_query.focus_takes_touch;
pthread_mutex_unlock(&g_query_lock);
return takes_touch;
}
int droiddeck_cursor_input_pos(int *x, int *y) {
pthread_mutex_lock(&g_query_lock);
int has_position = g_query.has_position;
int input_x = g_query.input_x, input_y = g_query.input_y;
pthread_mutex_unlock(&g_query_lock);
if (!has_position) return 0;
*x = input_x;
*y = input_y;
return 1;
}
static void touch_down(int id, double x, double y) {
if (id < 0 || id >= MAX_TOUCH_POINTS) return;
struct surface *target = touch_target_at(x, y);
@@ -3203,6 +3286,7 @@ static void deliver_touch(const struct input_msg *m) {
else if (m->p1 == 1) touch_motion(m->p2, x, y);
else if (m->p1 == 2) touch_up(m->p2, x, y);
else if (m->p1 == 3) touch_cancel_all();
publish_query_state();
}
static void key_event(uint32_t evdev, int pressed);
@@ -3220,10 +3304,10 @@ static void send_axis(struct seat_pointer *sp, uint32_t t, uint32_t axis, int va
wl_pointer_send_axis(sp->ptr, t, axis, wl_fixed_from_double(value120 / 12.0));
}
static void axis_event(int vertical120, int horizontal120) {
static void axis_event(int vertical120, int horizontal120, int finger) {
struct surface *target = g_active_constraint ? g_active_constraint->surface
: g_desktop ? g_desktop : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y));
if (!target || (!vertical120 && !horizontal120)) return;
if (!target || (!finger && !vertical120 && !horizontal120)) return;
struct wl_client *client = wl_resource_get_client(target->resource);
struct seat_pointer *sp;
if (!pointer_for(client)) return;
@@ -3232,16 +3316,33 @@ static void axis_event(int vertical120, int horizontal120) {
uint32_t t = now_ms();
pointer_focus(target->resource, wl_fixed_from_double(g_ptr_x - tx), wl_fixed_from_double(g_ptr_y - ty));
for_each_pointer_of(client, sp) {
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
int version = wl_resource_get_version(sp->ptr);
if (!finger) {
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
} else {
if (version >= WL_POINTER_AXIS_SOURCE_SINCE_VERSION)
wl_pointer_send_axis_source(sp->ptr, WL_POINTER_AXIS_SOURCE_FINGER);
if (!vertical120 && !horizontal120) {
if (version >= WL_POINTER_AXIS_STOP_SINCE_VERSION) {
wl_pointer_send_axis_stop(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL);
wl_pointer_send_axis_stop(sp->ptr, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL);
}
} else {
if (vertical120)
wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL, wl_fixed_from_double(vertical120 / 12.0));
if (horizontal120)
wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, wl_fixed_from_double(horizontal120 / 12.0));
}
}
if (version >= WL_POINTER_FRAME_SINCE_VERSION)
wl_pointer_send_frame(sp->ptr);
}
wl_display_flush_clients(g_display);
}
static void scroll_event(int steps) {
axis_event(steps * 120, 0);
axis_event(steps * 120, 0, 0);
}
static int update_modifiers(uint32_t evdev, int pressed) {
@@ -3337,7 +3438,8 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
schedule_render();
break;
case 10: break;
case 11: axis_event(m.p1, m.p2); break;
case 11: axis_event(m.p1, m.p2, 0); break;
case 12: axis_event(m.p1, m.p2, 1); break;
case 20: stream_take(); break;
case 21: if (!m.p1) stream_forget_all(); schedule_render(); break;
default: deliver_pointer(&m); break;
@@ -0,0 +1,99 @@
#include <fcntl.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "hudframes.h"
/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */
#define HUD_FRAMES_MAGIC 0x46484444u
#define HUD_FRAMES_HEADER 128u
#define HUD_FRAMES_ENTRY 16u
struct hud_frames {
const uint8_t *map;
size_t size;
uint64_t seen;
};
static uint32_t load32(const uint8_t *p) { return __atomic_load_n((const uint32_t *)p, __ATOMIC_ACQUIRE); }
static uint64_t load64(const uint8_t *p) { return __atomic_load_n((const uint64_t *)p, __ATOMIC_ACQUIRE); }
struct hud_frames *hud_frames_open(const char *path) {
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return NULL;
struct stat st;
void *map = MAP_FAILED;
if (fstat(fd, &st) == 0 && st.st_size >= (off_t)HUD_FRAMES_HEADER)
map = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (map == MAP_FAILED) return NULL;
struct hud_frames *h = calloc(1, sizeof(*h));
if (!h) {
munmap(map, (size_t)st.st_size);
return NULL;
}
h->map = map;
h->size = (size_t)st.st_size;
return h;
}
void hud_frames_close(struct hud_frames *h) {
if (!h) return;
munmap((void *)h->map, h->size);
free(h);
}
static uint32_t capacity(const struct hud_frames *h) {
if (load32(h->map) != HUD_FRAMES_MAGIC) return 0;
uint32_t cap;
memcpy(&cap, h->map + 8, 4);
if (cap == 0 || HUD_FRAMES_HEADER + (size_t)cap * HUD_FRAMES_ENTRY > h->size) return 0;
return cap;
}
int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max) {
uint32_t cap = capacity(h);
if (!cap || max <= 0) return -1;
uint64_t count = load64(h->map + 16);
if (count < h->seen) h->seen = 0;
uint64_t from = h->seen;
if (count - from > cap) from = count - cap;
if (count - from > (uint64_t)max) from = count - (uint64_t)max;
int n = 0;
for (uint64_t i = from; i < count; i++, n++) {
const uint8_t *e = h->map + HUD_FRAMES_HEADER + (i % cap) * HUD_FRAMES_ENTRY;
memcpy(&frametimes[n], e, 8);
memcpy(&times[n], e + 8, 8);
}
/* The writer may have lapped the ring while we copied: drop what it overwrote. */
uint64_t after = load64(h->map + 16);
uint64_t valid = after > cap ? after - cap : 0;
if (from < valid) {
int skip = (int)(valid - from < (uint64_t)n ? valid - from : (uint64_t)n);
memmove(frametimes, frametimes + skip, (size_t)(n - skip) * sizeof(int64_t));
memmove(times, times + skip, (size_t)(n - skip) * sizeof(int64_t));
n -= skip;
}
h->seen = count;
return n;
}
int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len) {
if (!capacity(h) || engine_len == 0) return 0;
for (int tries = 0; tries < 8; tries++) {
uint32_t seq = load32(h->map + 24);
if (seq & 1u) continue;
memcpy(&info[0], h->map + 12, 4);
memcpy(&info[1], h->map + 28, 16);
size_t n = engine_len - 1 < 40 ? engine_len - 1 : 40;
memcpy(engine, h->map + 44, n);
engine[n] = '\0';
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (__atomic_load_n((const uint32_t *)(h->map + 24), __ATOMIC_RELAXED) == seq) return 1;
}
return 0;
}
@@ -0,0 +1,16 @@
#ifndef DROIDDECK_HUDFRAMES_H
#define DROIDDECK_HUDFRAMES_H
#include <stddef.h>
#include <stdint.h>
struct hud_frames;
struct hud_frames *hud_frames_open(const char *path);
void hud_frames_close(struct hud_frames *h);
/* New frames since the last poll, oldest first; -1 while the file holds no valid ring. */
int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max);
/* info: pid, output width, output height, refresh mHz, Steam focused. */
int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len);
#endif
@@ -0,0 +1,55 @@
#include <jni.h>
#include <stdint.h>
#include <stdlib.h>
#include "hudframes.h"
JNIEXPORT jlong JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativeOpen(JNIEnv *env, jclass clazz, jstring path) {
(void)clazz;
const char *p = path ? (*env)->GetStringUTFChars(env, path, NULL) : NULL;
if (!p) return 0;
struct hud_frames *h = hud_frames_open(p);
(*env)->ReleaseStringUTFChars(env, path, p);
return (jlong)(intptr_t)h;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativeClose(JNIEnv *env, jclass clazz, jlong handle) {
(void)env; (void)clazz;
hud_frames_close((struct hud_frames *)(intptr_t)handle);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativePoll(JNIEnv *env, jclass clazz, jlong handle,
jlongArray frametimes, jlongArray times) {
(void)clazz;
struct hud_frames *h = (struct hud_frames *)(intptr_t)handle;
if (!h || !frametimes || !times) return -1;
jsize max = (*env)->GetArrayLength(env, frametimes);
jsize max2 = (*env)->GetArrayLength(env, times);
if (max2 < max) max = max2;
if (max <= 0) return -1;
int64_t *ft = malloc(sizeof(int64_t) * (size_t)max);
int64_t *tm = malloc(sizeof(int64_t) * (size_t)max);
int n = ft && tm ? hud_frames_poll(h, ft, tm, max) : -1;
if (n > 0) {
(*env)->SetLongArrayRegion(env, frametimes, 0, n, (const jlong *)ft);
(*env)->SetLongArrayRegion(env, times, 0, n, (const jlong *)tm);
}
free(ft);
free(tm);
return n;
}
JNIEXPORT jstring JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativeInfo(JNIEnv *env, jclass clazz, jlong handle, jintArray info) {
(void)clazz;
const struct hud_frames *h = (const struct hud_frames *)(intptr_t)handle;
if (!h || !info || (*env)->GetArrayLength(env, info) < 5) return NULL;
int32_t values[5];
char engine[41];
if (!hud_frames_info(h, values, engine, sizeof(engine))) return NULL;
(*env)->SetIntArrayRegion(env, info, 0, 5, (const jint *)values);
return (*env)->NewStringUTF(env, engine);
}
@@ -384,6 +384,13 @@ int vkp_output_to_scene(double ox, double oy, double *sx, double *sy) {
return 1;
}
int vkp_scene_to_output(double sx, double sy, double *ox, double *oy) {
if (!g_map.valid || g_map.kx <= 0 || g_map.ky <= 0) return 0;
*ox = sx * g_map.kx + g_map.off_x;
*oy = sy * g_map.ky + g_map.off_y;
return 1;
}
void vkp_output_size(int *w, int *h) {
*w = (int)g_extent.width;
*h = (int)g_extent.height;
@@ -16,6 +16,7 @@ enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
void vk_present_set_scale_mode(int mode, int alignment);
int vkp_game_picture_rect(float rect[4]);
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
int vkp_scene_to_output(double sx, double sy, double *ox, double *oy);
void vkp_output_size(int *w, int *h);
struct vkp_image;
@@ -27,6 +27,8 @@ extern int droiddeck_cursor_snapshot(int *out, int cap);
extern int droiddeck_cursor_serial(void);
extern int droiddeck_hud_frame_times(float *out, int len, int64_t *total);
extern void droiddeck_wayland_send_key(int evdev, int state);
extern int droiddeck_focus_takes_touch(void);
extern int droiddeck_cursor_input_pos(int *x, int *y);
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
extern void droiddeck_stream_set(int on);
@@ -296,6 +298,25 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
droiddeck_wayland_send_key(evdev, state);
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeFocusTakesTouch(
JNIEnv *env, jclass clazz) {
(void)env; (void)clazz;
return droiddeck_focus_takes_touch() ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorInputPos(
JNIEnv *env, jclass clazz, jintArray out) {
(void)clazz;
if (!out || (*env)->GetArrayLength(env, out) < 2) return JNI_FALSE;
int pos[2];
if (!droiddeck_cursor_input_pos(&pos[0], &pos[1])) return JNI_FALSE;
jint jpos[2] = {pos[0], pos[1]};
(*env)->SetIntArrayRegion(env, out, 0, 2, jpos);
return JNI_TRUE;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput(
JNIEnv *env, jclass clazz, jint type, jint a, jint b) {
@@ -17,6 +17,7 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
com.droiddeck.launcher.ui.Themes.loadGradient(this)
com.droiddeck.launcher.ui.BuildWatermark.install(this)
// Helper processes must not initialize the main USB provider or guest graphics context.
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
@@ -34,8 +35,10 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.init(this)
return
}
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
process == "$packageName:shortcuts_sync") return
CrashHandler.install(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
@@ -46,6 +49,7 @@ class App : Application() {
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
BwrapSpawner.start(this)
com.droiddeck.launcher.runtime.RootSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
com.droiddeck.launcher.stores.StoresState.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
@@ -46,6 +46,7 @@ import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SamsungPerf
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.DownloadSource
@@ -109,6 +110,8 @@ class FrontEndActivity : ComponentActivity() {
private var ready by mutableStateOf(false)
private var removalPending by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var catalogFresh = false
private var componentsQueueBusy by mutableStateOf(false)
private var busy by mutableStateOf(false)
private var stage by mutableStateOf("")
private var percent by mutableIntStateOf(-1)
@@ -169,6 +172,8 @@ class FrontEndActivity : ComponentActivity() {
private var zramClient by mutableStateOf(true)
private var vramCapMb by mutableStateOf(0)
private var gpuClockPin by mutableStateOf(false)
private var performanceBoost by mutableStateOf(false)
private var samsungSdk by mutableStateOf<Boolean?>(null)
private var prootNoSeccomp by mutableStateOf(false)
private var prootFastPath by mutableStateOf(true)
private var cursorComposite by mutableStateOf(false)
@@ -193,7 +198,7 @@ class FrontEndActivity : ComponentActivity() {
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 storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
@@ -213,6 +218,7 @@ class FrontEndActivity : ComponentActivity() {
}
private val mediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
usbScan++
ui.removeCallbacks(mediaRefresh)
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
}
@@ -251,6 +257,9 @@ class FrontEndActivity : ComponentActivity() {
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private val pickSamsungSdk = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importSamsungSdk(it) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
@@ -321,7 +330,10 @@ class FrontEndActivity : ComponentActivity() {
SteamIntegration.Feature.SHORTCUTS -> {
if (!integrationState.shortcuts) shortcutsTried = true
if (!HomeApp.isDefault(this)) configureIntegration(SteamIntegration.Feature.HOME)
else integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
else {
SteamIntegration.setShortcutsListed(this, true)
integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
}
}
}
}
@@ -389,6 +401,27 @@ class FrontEndActivity : ComponentActivity() {
}, "lossless-import").start()
}
private fun importSamsungSdk(source: File) {
Thread({
val message = getString(
when (SamsungPerf.import(this, source)) {
SamsungPerf.ImportResult.IMPORTED -> R.string.perf_samsung_sdk_imported
SamsungPerf.ImportResult.INVALID -> R.string.perf_samsung_sdk_invalid
SamsungPerf.ImportResult.FAILED -> R.string.perf_samsung_sdk_failed
},
)
val imported = SamsungPerf.imported(this)
ui.post {
samsungSdk = imported
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "samsung-sdk-import").start()
}
private fun pickSamsungSdk() {
pickSamsungSdk.launch(InAppFilePicker.buildIntent(this, listOf("zip", "jar"), getString(R.string.perf_samsung_sdk_pick)))
}
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
@@ -472,6 +505,16 @@ class FrontEndActivity : ComponentActivity() {
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backActionsInverted by mutableStateOf(false)
private var gameStorage by mutableStateOf("")
private var slowUsb by mutableStateOf<com.droiddeck.launcher.session.UsbLink.SlowDrive?>(null)
private var usbScan = 0
private var slowUsbNever by mutableStateOf(false)
private fun showSlowUsb(drive: com.droiddeck.launcher.session.UsbLink.SlowDrive?) {
val off = slowUsbNever
if (off) SessionPrefs.setSlowUsbWarningOff(this)
slowUsbNever = false
slowUsb = drive.takeUnless { off }
}
private var storageDiagnostics by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
@@ -514,12 +557,23 @@ class FrontEndActivity : ComponentActivity() {
}
}
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var dualDisplayId by mutableStateOf(-1)
private var dualScreenDevice by mutableStateOf(false)
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenLauncher by mutableStateOf(true)
private var dualScreenControls by mutableStateOf(true)
private var launcherStarted = false
private lateinit var displayManager: DisplayManager
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
}
private val secondScreenTouchListener = object : android.hardware.input.InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) = refreshSecondScreenDisplays()
override fun onInputDeviceRemoved(deviceId: Int) = refreshSecondScreenDisplays()
override fun onInputDeviceChanged(deviceId: Int) = refreshSecondScreenDisplays()
}
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshHomeAppState()
@@ -649,7 +703,23 @@ class FrontEndActivity : ComponentActivity() {
private var shortcutPicker by mutableStateOf(false)
private var shortcutLibraryScanning by mutableStateOf(false)
@Volatile private var libraryScanGeneration = 0
@Volatile private var lastComponentRestore = 0L
@Volatile private var componentRestoreRetry = false
private val componentRestoreLock = Any()
private fun retryComponentRestore() {
val run = synchronized(componentRestoreLock) {
componentRestoreRetry = false
val now = android.os.SystemClock.elapsedRealtime()
(!isDestroyed && now - lastComponentRestore >= COMPONENT_RESTORE_MS).also { if (it) lastComponentRestore = now }
}
if (run) Thread({
runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) }
}, "components-restore").start()
}
private var pendingGameLink: String? = null
private var gameLinkReading = false
private var gameLinkTried = false
private var gameSyncFolder by mutableStateOf<String?>(null)
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
@@ -690,16 +760,25 @@ class FrontEndActivity : ComponentActivity() {
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
gameLinkReading = false
gameLinkTried = false
if (GameLaunchIntent.accepts(request.action)) {
gameLinkReading = true
com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
val copy = Intent(request)
Thread({
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
gameLinkReading = false
pendingGameLink = id
if (id == null) {
syncDualPanel()
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
} else refresh()
} else {
handleGameLink(steamGames, scanned = false)
refresh()
}
}
}
}, "game-launch-file").start()
@@ -902,15 +981,21 @@ class FrontEndActivity : ComponentActivity() {
ui.postDelayed(poll, SESSION_SWITCH_POLL_MS)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
private fun handleGameLink(games: List<Library.SteamGame>, scanned: Boolean = true) {
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
val id = pendingGameLink ?: return
if (!scanned && games.none { it.gameIdString == id }) return
gameLinkTried = true
ui.postDelayed({
if (!isDestroyed && lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) syncDualPanel()
}, LINK_PANEL_MS)
val game = games.firstOrNull { it.gameIdString == id }
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
syncDualPanel()
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game, returnHome = true)) {
pendingGameLink = null
@@ -965,6 +1050,13 @@ class FrontEndActivity : ComponentActivity() {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
)
slowUsb?.let { drive ->
com.droiddeck.launcher.ui.SlowUsbDialog(drive, slowUsbNever, { slowUsbNever = it }) {
com.droiddeck.launcher.session.UsbLink.markShown(this, drive)
showSlowUsb(null)
checkUsbLink()
}
}
val page: (@Composable () -> Unit)? = when {
showPerformance -> { { PerformanceHost() } }
showComponents -> { { ComponentsHost() } }
@@ -999,6 +1091,11 @@ class FrontEndActivity : ComponentActivity() {
integrationFocus = integrationFocus,
androidApps = androidApps,
secondScreenDisplays = secondScreenDisplays,
dualScreen = dualDisplayId >= 0 && com.droiddeck.launcher.ui.DualScreenPanel.available,
dualScreenDevice = dualScreenDevice,
dualScreenMode = dualScreenMode,
dualScreenLauncher = dualScreenLauncher,
dualScreenControls = dualScreenControls,
packages = packageRows,
packageCatalogLoading = catalogLoading,
packageBusyId = pkgId,
@@ -1227,6 +1324,18 @@ class FrontEndActivity : ComponentActivity() {
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view },
onLibraryChanged = { refreshAddedGames(); refresh() },
onDualScreenMode = { mode ->
SessionPrefs.setDualScreenMode(this, mode)
refreshSecondScreenDisplays()
},
onDualScreenLauncher = { on ->
SessionPrefs.setDualScreenLauncher(this, on)
refreshSecondScreenDisplays()
},
onDualScreenControls = { on ->
SessionPrefs.setDualScreenSessionControls(this, on)
dualScreenControls = on
},
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
@@ -1315,7 +1424,7 @@ class FrontEndActivity : ComponentActivity() {
showPhantomGate = true
},
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onStartPairingGuide = { displayId -> PairingGuideActivity.start(this, displayId) },
onStartPairingGuide = { displayId, toggleOnly -> PairingGuideActivity.start(this, displayId, toggleOnly) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
@@ -1457,6 +1566,9 @@ class FrontEndActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
if (intent.action == SteamIntegration.ACTION_SETUP) {
gameLinkReading = false
pendingGameLink = null
syncDualPanel()
readIntegrationIntent(intent)
return
}
@@ -1550,15 +1662,18 @@ class FrontEndActivity : ComponentActivity() {
override fun onPause() {
compatLaunchVisible = false
if (android.os.Build.VERSION.SDK_INT >= 29) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
super.onPause()
}
override fun onResume() {
super.onResume()
compatLaunchVisible = true
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
orientation = SessionPrefs.orientation(this)
launcherOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
requestedOrientation = launcherOrientation
SteamIntegration.syncShortcutsListing(this)
refreshWifiDiscovery()
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
@@ -1573,14 +1688,21 @@ class FrontEndActivity : ComponentActivity() {
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post { if (showComponents) components.refreshComponents() }
}, "components-queue").start()
else if (showComponents) components.refreshComponents()
catalogFresh = false
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
componentsQueueBusy = true
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
autoUpdateRuntime()
}
}, "components-queue").start()
} else if (showComponents) components.refreshComponents()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
@@ -1603,6 +1725,8 @@ class FrontEndActivity : ComponentActivity() {
addDataScheme("file")
})
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
getSystemService(android.hardware.input.InputManager::class.java)?.registerInputDeviceListener(secondScreenTouchListener, ui)
launcherStarted = true
refreshSecondScreenDisplays()
getSystemService(android.content.pm.LauncherApps::class.java).registerCallback(packageCallback, ui)
HomeWidgets.startListening(this)
@@ -1617,6 +1741,7 @@ class FrontEndActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (slowUsbNever) SessionPrefs.setSlowUsbWarningOff(this)
saveCompatLaunch(outState)
pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) }
switchingTo?.let {
@@ -1644,6 +1769,9 @@ class FrontEndActivity : ComponentActivity() {
com.droiddeck.launcher.stores.StoresState.libraryListener = null
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
getSystemService(android.hardware.input.InputManager::class.java)?.unregisterInputDeviceListener(secondScreenTouchListener)
launcherStarted = false
com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
flood = null
returning = null
floodProgress = 0f
@@ -1677,13 +1805,31 @@ class FrontEndActivity : ComponentActivity() {
if (!::displayManager.isInitialized) return
// The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
dualScreenMode = SessionPrefs.dualScreenMode(this)
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
dualScreenLauncher = SessionPrefs.dualScreenLauncher(this)
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
dualDisplayId = if (dualScreenLauncher) target?.displayId ?: -1 else -1
syncDualPanel()
}
private fun syncDualPanel() {
if (launchPending()) return
if (launcherStarted && dualDisplayId >= 0) com.droiddeck.launcher.ui.DualScreenPanel.show(this, dualDisplayId, taskId)
else com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
}
private fun launchPending(): Boolean = gameLinkReading || (pendingGameLink != null && !gameLinkTried)
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
val onOwnScreen = displayId == null || displayId == (display?.displayId ?: Display.DEFAULT_DISPLAY)
if (onOwnScreen) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
try {
HomeApp.launch(this, app, displayId)
} catch (_: Exception) {
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
@@ -2103,6 +2249,7 @@ class FrontEndActivity : ComponentActivity() {
fsyncFirst = fsyncFirst,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
performanceBoost = performanceBoost, samsungSdk = samsungSdk,
ramCapMb = ramCapMb, vramCapMb = vramCapMb, zramClient = zramClient,
deviceRamMb = (android.app.ActivityManager.MemoryInfo()
.also((getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)::getMemoryInfo).totalMem / (1024 * 1024)).toInt(),
@@ -2120,6 +2267,12 @@ class FrontEndActivity : ComponentActivity() {
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onPerformanceBoost = { on ->
SessionPrefs.setPerformanceBoost(this, on)
performanceBoost = on
if (on && samsungSdk == false) pickSamsungSdk()
},
onImportSamsungSdk = { pickSamsungSdk() },
onRamCapMb = { mb -> SessionPrefs.setRamCapMb(this, mb); ramCapMb = mb },
onZramClient = { on -> SessionPrefs.setZramClient(this, on); zramClient = on },
onVramCapMb = { mb -> SessionPrefs.setVramCapMb(this, mb); vramCapMb = mb },
@@ -2198,6 +2351,7 @@ class FrontEndActivity : ComponentActivity() {
}
private fun refreshAddedGames() {
checkUsbLink()
sgdbKeyKnown()
fetchAddedGameArt()
}
@@ -2302,6 +2456,20 @@ class FrontEndActivity : ComponentActivity() {
}
SessionPrefs.setGameStorage(this, path, label)
gameStorage = path
checkUsbLink()
}
private fun checkUsbLink() {
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { ui.post { checkUsbLink() }; return }
val scan = ++usbScan
Thread({
val drive = runCatching { com.droiddeck.launcher.session.UsbLink.slowLibrary(this) }.getOrNull()
ui.post {
if (scan != usbScan || isFinishing) return@post
val due = drive?.takeIf { com.droiddeck.launcher.session.UsbLink.stillDue(this, it) }
if (due == null) { if (slowUsb != null) showSlowUsb(null) } else if (slowUsb?.key != due.key) showSlowUsb(due)
}
}, "usb-link").start()
}
private fun refreshCores() {
@@ -2321,6 +2489,8 @@ class FrontEndActivity : ComponentActivity() {
zramClient = SessionPrefs.zramClient(this)
vramCapMb = SessionPrefs.vramCapMb(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
performanceBoost = SessionPrefs.performanceBoost(this)
samsungSdk = if (SamsungPerf.offered(this)) SamsungPerf.imported(this) else null
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
prootFastPath = SessionPrefs.prootFastPath(this)
cursorComposite = SessionPrefs.cursorComposite(this)
@@ -2350,6 +2520,7 @@ class FrontEndActivity : ComponentActivity() {
installed = LinuxRuntimeInstaller.installedVersion(this)
ready = LinuxRuntime.isInstalled(this)
removalPending = LinuxRuntimeInstaller.hasRemovalPending(this)
ui.post { autoUpdateRuntime() }
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
@@ -2392,15 +2563,32 @@ class FrontEndActivity : ComponentActivity() {
}
val scannedArch = SessionPrefs.activeSteamClientArch(this)
val scannedX64 = scannedArch == "x86_64"
val games = if (ready) Library.launchableGames(this, arch = scannedArch) else emptyList()
val games = if (ready || com.droiddeck.launcher.runtime.LinuxRuntime.hasRootfs(this)) Library.launchableGames(this, arch = scannedArch) else emptyList()
val all = games.distinctBy { it.gameId }
if (ready && scanGeneration == libraryScanGeneration) com.droiddeck.launcher.frontend.LibraryCache.save(this, all, scannedX64)
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
if (ready && (!componentSweepDone || endedSession != null)) {
val sweepDue = ready && (!componentSweepDone || endedSession != null)
if (sweepDue) {
componentSweepDone = true
endedSession?.let { componentSweepSession = it }
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
}
val restoreDue = synchronized(componentRestoreLock) {
val now = android.os.SystemClock.elapsedRealtime()
val due = ready && (lastComponentRestore == 0L || now - lastComponentRestore >= COMPONENT_RESTORE_MS)
if (due) {
lastComponentRestore = now
} else if (ready && !componentRestoreRetry) {
componentRestoreRetry = true
ui.postDelayed({ retryComponentRestore() }, (COMPONENT_RESTORE_MS - (now - lastComponentRestore)).coerceAtLeast(0L))
}
due
}
if (restoreDue || sweepDue) ui.postDelayed({
Thread({
if (restoreDue) runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) }
if (sweepDue) com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all)
}, "components-restore").start()
}, 10_000)
ui.post {
if (scanGeneration == libraryScanGeneration) {
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
@@ -2494,7 +2682,7 @@ class FrontEndActivity : ComponentActivity() {
private fun runtimeChangesBlocked(): Boolean =
busy || LinuxRuntimeInstaller.isBusy() || SessionState.running ||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null || componentsQueueBusy ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeDesktop() {
@@ -2554,15 +2742,32 @@ class FrontEndActivity : ComponentActivity() {
}, "runtime-operation").start()
}
private fun autoUpdateRuntime() {
val release = available?.takeIf { catalogFresh } ?: return
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
if (release.version == installed || installed == null) return
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
install(release)
}
}
private fun checkCatalog() {
val release = LinuxRuntimeInstaller.fetchRelease()
Log.i(TAG, "catalog: " + (release?.version ?: "unreachable"))
ui.post { if (release != null) available = release }
ui.post {
if (release == null) return@post
available = release
catalogFresh = true
autoUpdateRuntime()
}
}
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
private const val COMPONENT_RESTORE_MS = 60_000L
private const val LINK_PANEL_MS = 1500L
private const val SESSION_SWITCH_WAIT_MS = 30_000L
private const val SESSION_SWITCH_POLL_MS = 250L
private const val KEY_PENDING_SWITCH = "pending_switch"
@@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.GlobalSettings
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage
import com.droiddeck.launcher.ui.DroidDeckTheme
@@ -67,9 +68,10 @@ import com.droiddeck.launcher.ui.SecondaryButton
import kotlinx.coroutines.delay
class PairingGuideActivity : ComponentActivity() {
enum class Step { BUILD_NUMBER, WIRELESS_DEBUGGING, PAIR, CODE, WORKING, DONE, FAILED }
enum class Step { BUILD_NUMBER, CHILD_PROCESS, WIRELESS_DEBUGGING, PAIR, CODE, WORKING, DONE, FAILED }
private var settingsDisplay: Int? = null
private var toggleOnly = false
private var inPip by mutableStateOf(false)
private var launchPending = false
@@ -78,6 +80,7 @@ class PairingGuideActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settingsDisplay = intent.getIntExtra(EXTRA_DISPLAY, -1).takeIf { it >= 0 }
toggleOnly = intent.getBooleanExtra(EXTRA_TOGGLE_ONLY, false)
setContent { DroidDeckTheme { Guide() } }
launchPending = savedInstanceState == null
}
@@ -86,6 +89,7 @@ class PairingGuideActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
settingsDisplay = intent.getIntExtra(EXTRA_DISPLAY, -1).takeIf { it >= 0 }
toggleOnly = intent.getBooleanExtra(EXTRA_TOGGLE_ONLY, false)
launchPending = true
}
@@ -108,7 +112,7 @@ class PairingGuideActivity : ComponentActivity() {
private fun launchSettings() {
launchPending = false
openSettings(stepFor(this, WirelessAdbPairingService.stage.value))
openSettings(stepFor(SettingsState.read(this), WirelessAdbPairingService.stage.value, toggleOnly))
}
private fun enterPip() {
@@ -121,7 +125,9 @@ class PairingGuideActivity : ComponentActivity() {
val intent = if (step == Step.BUILD_NUMBER) {
Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).putExtra(HIGHLIGHT_KEY, "build_number")
} else {
Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).putExtra(HIGHLIGHT_KEY, "toggle_adb_wireless")
Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply {
if (step != Step.CHILD_PROCESS) putExtra(HIGHLIGHT_KEY, "toggle_adb_wireless")
}
}.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val display = settingsDisplay
runCatching {
@@ -147,10 +153,10 @@ class PairingGuideActivity : ComponentActivity() {
delay(500)
}
}
val step = stepFor(settingsState, stage)
val step = stepFor(settingsState, stage, toggleOnly)
var previous by remember { mutableStateOf(step) }
LaunchedEffect(step) {
if (previous == Step.BUILD_NUMBER && step == Step.WIRELESS_DEBUGGING) openSettings(step)
if (previous == Step.BUILD_NUMBER && (step == Step.WIRELESS_DEBUGGING || step == Step.CHILD_PROCESS)) openSettings(step)
previous = step
when (step) {
Step.DONE -> { delay(1_500); returnToApp() }
@@ -158,7 +164,7 @@ class PairingGuideActivity : ComponentActivity() {
else -> Unit
}
}
LaunchedEffect(stage) { if (stage == Stage.Idle) finish() }
LaunchedEffect(stage) { if (stage == Stage.Idle && !toggleOnly) finish() }
BackHandler(enabled = !inPip && settingsDisplay == null) { enterPip() }
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center) {
@@ -199,7 +205,7 @@ class PairingGuideActivity : ComponentActivity() {
openSettings(step)
})
SecondaryButton(stringResource(R.string.common_cancel), onClick = {
WirelessAdbPairingService.cancel(this@PairingGuideActivity)
if (!toggleOnly) WirelessAdbPairingService.cancel(this@PairingGuideActivity)
returnToApp()
})
}
@@ -210,6 +216,7 @@ class PairingGuideActivity : ComponentActivity() {
@Composable
private fun instruction(step: Step, stage: Stage): String = when (step) {
Step.BUILD_NUMBER -> stringResource(R.string.pairguide_build_number)
Step.CHILD_PROCESS -> stringResource(R.string.pairguide_child_process)
Step.WIRELESS_DEBUGGING -> stringResource(R.string.pairguide_wireless)
Step.PAIR -> stringResource(R.string.pairguide_pair)
Step.CODE -> (stage as? Stage.CodeNeeded)?.error ?: stringResource(R.string.gate_step3)
@@ -226,6 +233,7 @@ class PairingGuideActivity : ComponentActivity() {
val beat by pulse.animateFloat(0f, 1f, infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Restart), label = "beat")
val label = when (step) {
Step.BUILD_NUMBER -> stringResource(R.string.pairguide_row_build_number)
Step.CHILD_PROCESS -> stringResource(R.string.pairguide_row_child_process)
Step.WIRELESS_DEBUGGING -> stringResource(R.string.pairguide_row_wireless)
Step.PAIR -> stringResource(R.string.pairguide_row_pair)
Step.CODE -> stringResource(R.string.adbpair_enter_code)
@@ -240,7 +248,7 @@ class PairingGuideActivity : ComponentActivity() {
Text(label, style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyLarge,
color = colors.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
when (step) {
Step.WIRELESS_DEBUGGING -> Toggle(on = beat > 0.5f, accent = accent, compact = compact)
Step.WIRELESS_DEBUGGING, Step.CHILD_PROCESS -> Toggle(on = beat > 0.5f, accent = accent, compact = compact)
else -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (step == Step.BUILD_NUMBER) Text("×7", style = MaterialTheme.typography.labelLarge, color = accent)
Box(contentAlignment = Alignment.Center) {
@@ -267,7 +275,8 @@ class PairingGuideActivity : ComponentActivity() {
@Composable
private fun StepDots(step: Step) {
val accent = LocalPalette.current.signal
val shown = listOf(Step.BUILD_NUMBER, Step.WIRELESS_DEBUGGING, Step.PAIR, Step.CODE)
val shown = if (toggleOnly) listOf(Step.BUILD_NUMBER, Step.CHILD_PROCESS)
else listOf(Step.BUILD_NUMBER, Step.WIRELESS_DEBUGGING, Step.PAIR, Step.CODE)
val at = shown.indexOf(step).takeIf { it >= 0 } ?: shown.size
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
shown.forEachIndexed { index, _ ->
@@ -278,22 +287,30 @@ class PairingGuideActivity : ComponentActivity() {
}
}
private data class SettingsState(val developerOptions: Boolean, val wirelessDebugging: Boolean) {
private data class SettingsState(
val developerOptions: Boolean,
val wirelessDebugging: Boolean,
val childProcessBlocked: Boolean,
) {
companion object {
fun read(context: Context) = SettingsState(
GlobalSettings.developerOptionsOn(context),
GlobalSettings.wirelessDebuggingOn(context),
PhantomProcessLimit.blocksSteam(PhantomProcessLimit.read(context)),
)
}
}
companion object {
private const val EXTRA_DISPLAY = "settingsDisplay"
private const val EXTRA_TOGGLE_ONLY = "toggleOnly"
private const val HIGHLIGHT_KEY = ":settings:fragment_args_key"
private fun stepFor(context: Context, stage: Stage) = stepFor(SettingsState.read(context), stage)
private fun stepFor(settings: SettingsState, stage: Stage): Step = when (stage) {
private fun stepFor(settings: SettingsState, stage: Stage, toggleOnly: Boolean): Step = if (toggleOnly) when {
!settings.developerOptions -> Step.BUILD_NUMBER
settings.childProcessBlocked -> Step.CHILD_PROCESS
else -> Step.DONE
} else when (stage) {
is Stage.Working -> Step.WORKING
Stage.Done -> Step.DONE
is Stage.Failed -> Step.FAILED
@@ -317,10 +334,11 @@ class PairingGuideActivity : ComponentActivity() {
return mode == AppOpsManager.MODE_ALLOWED
}
fun start(context: Context, settingsDisplay: Int?): Boolean {
fun start(context: Context, settingsDisplay: Int?, toggleOnly: Boolean = false): Boolean {
if (settingsDisplay == null && !canFloat(context)) return false
context.startActivity(Intent(context, PairingGuideActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOGGLE_ONLY, toggleOnly)
.apply { if (settingsDisplay != null) putExtra(EXTRA_DISPLAY, settingsDisplay) })
return true
}
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,14 @@ object AgentGuest {
private set
var steamKeyboard by mutableStateOf(false)
private set
var desktopTextField by mutableStateOf(false)
private set
var keyboardRequests by mutableIntStateOf(0)
private set
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@Volatile private var routeDir: File? = null
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -35,10 +43,12 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false }
main.post { steamKeyboard = false; desktopTextField = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
routeDir = dir
writeKeyboardRoute(dir, keyboardRoute)
@Suppress("DEPRECATION")
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
@@ -48,6 +58,9 @@ object AgentGuest {
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
}
}
}.also { it.startWatching() }
@@ -58,10 +71,46 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false }
main.post { steamKeyboard = false; desktopTextField = false }
}
}
fun routeKeyboard(context: Context, bottom: Boolean) {
synchronized(lock) {
if (keyboardRoute == bottom) return
keyboardRoute = bottom
writeKeyboardRoute(routeDir ?: dir(context), bottom)
}
}
private fun writeKeyboardRoute(dir: File, bottom: Boolean) {
val file = File(dir, "keyboard-route")
runCatching {
if (!bottom) file.delete()
else if (dir.isDirectory) {
val temp = File(dir, "keyboard-route.tmp")
temp.writeText("bottom")
if (!temp.renameTo(file)) temp.delete()
}
}.onFailure { Log.w(TAG, "keyboard route not written", it) }
}
private fun recordKeyboardRequest(source: FileObserver) {
synchronized(lock) {
if (source !== observer) return
main.post { keyboardRequests++ }
}
SessionEvents.record("keyboard.requested", emptyMap())
}
private fun recordKeyboardDismiss(source: FileObserver) {
synchronized(lock) {
if (source !== observer) return
main.post { keyboardDismissals++ }
}
SessionEvents.record("keyboard.dismissed", emptyMap())
}
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
val showing = snapshot.optBoolean("steamKeyboard")
synchronized(lock) {
@@ -71,6 +120,15 @@ object AgentGuest {
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
}
private fun recordTextField(source: FileObserver, snapshot: JSONObject) {
val focused = snapshot.optBoolean("textField")
synchronized(lock) {
if (source !== observer) return
main.post { desktopTextField = focused }
}
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -23,6 +23,9 @@ class AgentStartActivity : Activity() {
Thread({
try {
AgentBridgeProvider.awaitRecovery()
val until = android.os.SystemClock.elapsedRealtime() + RUNTIME_WAIT_MS
while (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isBusy() && !isFinishing &&
android.os.SystemClock.elapsedRealtime() < until) Thread.sleep(RUNTIME_POLL_MS)
runOnUiThread {
if (!isFinishing) startSession(request)
}
@@ -51,6 +54,11 @@ class AgentStartActivity : Activity() {
}
private fun startSession(request: JSONObject) {
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isBusy()) {
Log.w(TAG, "the Linux runtime is being installed or changed")
finish()
return
}
try {
AgentAccess.requireStart(this, request)
} catch (e: AgentException) {
@@ -107,5 +115,7 @@ class AgentStartActivity : Activity() {
companion object {
const val EXTRA_REQUEST = "request"
private const val TAG = "AgentStartActivity"
private const val RUNTIME_WAIT_MS = 15 * 60_000L
private const val RUNTIME_POLL_MS = 500L
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.util.Log
object DirectAudioTiming {
private const val TAG = "DirectAudioTiming"
const val PERIOD_ENV = "AUDIO_DIRECT_PERIOD_MS"
const val BUFFER_ENV = "AUDIO_DIRECT_MS"
const val SLOW_OUTPUT_PERIOD_MS = 20
const val SLOW_OUTPUT_BUFFER_MS = 60
fun environment(lowLatencyOutput: Boolean): List<String> =
if (lowLatencyOutput) emptyList()
else listOf("$PERIOD_ENV=$SLOW_OUTPUT_PERIOD_MS", "$BUFFER_ENV=$SLOW_OUTPUT_BUFFER_MS")
fun lowLatencyOutput(): Boolean = try {
val track = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build())
.setAudioFormat(AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setSampleRate(48000)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build())
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
try {
track.performanceMode == AudioTrack.PERFORMANCE_MODE_LOW_LATENCY
} finally {
track.release()
}
} catch (e: Exception) {
Log.w(TAG, "audio: could not check for low-latency output; DirectAudio keeps its defaults", e)
true
}
}
@@ -146,5 +146,7 @@ internal class AddonPack(
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
val BUTLER = AddonPack("butler", BuildConfig.PACK_BUTLER_SHA256, BuildConfig.PACK_BUTLER_SIZE,
listOf("version", "butler", "7z.so", "libc7zip.so"))
}
}
@@ -34,6 +34,8 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("AUDIO_DIRECT_MS", "", 0, Type.NUMBER),
Option("AUDIO_DIRECT_PERIOD_MS", "", 0, Type.NUMBER),
Option("DROIDDECK_GAME_CORES", "", R.string.game_env_cores, Type.TEXT),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
@@ -0,0 +1,11 @@
package com.droiddeck.launcher.core
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class ShortcutsListingReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) SteamIntegration.syncShortcutsListing(context)
}
}
@@ -4,6 +4,7 @@ import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
@@ -55,6 +56,33 @@ object SteamIntegration {
return mode == android.app.AppOpsManager.MODE_ERRORED || mode == android.app.AppOpsManager.MODE_IGNORED
}
private fun shortcutsComponent(context: Context) = ComponentName(context, HomeButtonService::class.java)
fun shortcutsChosen(context: Context): Boolean = shortcutsSelection(context) == true
private fun shortcutsSelection(context: Context): Boolean? {
val target = shortcutsComponent(context)
val enabled = runCatching {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
}.getOrElse { return null }.orEmpty()
return enabled.split(':').any { ComponentName.unflattenFromString(it.trim()) == target }
}
fun setShortcutsListed(context: Context, listed: Boolean) {
val packages = context.packageManager
val component = shortcutsComponent(context)
val state = if (listed) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
runCatching {
if (packages.getComponentEnabledSetting(component) != state) {
packages.setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP)
}
}
}
fun syncShortcutsListing(context: Context) {
shortcutsSelection(context)?.let { setShortcutsListed(context, it) }
}
fun dismissSuggestion(context: Context) {
SessionPrefs.sessionPrefs(context).edit().putBoolean("steam_integration_seen", true).apply()
}
@@ -161,6 +161,25 @@ object AddedGames {
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
)
private val GAME_PARTS = setOf(
"bin", "bin32", "bin64", "binaries", "x64", "x86", "win64", "win32", "windows",
"launcher", "game", "engine", "system", "data", "app",
)
internal fun groupGames(folder: File): List<File>? {
val files = folder.listFiles() ?: return null
if (files.any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) }) return null
if (declaredExes(folder).isNotEmpty()) return null
val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") && !isSteamLibraryFolder(it.name) }.sortedBy { it.name.lowercase() }
if (dirs.any { it.name.lowercase() in GAME_PARTS || GameExePicker.auxiliaryDir(it) }) return null
return dirs.filter { rank(it).exes.isNotEmpty() }.takeIf { it.size >= 2 }
}
private fun expand(folder: File, entries: Set<String>): List<File> =
if (folder.path in entries) listOf(folder) else groupGames(folder) ?: listOf(folder)
private fun entryFolders(context: Context): Set<String> = AddedExes.list(context).map { it.folder }.toSet()
/** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */
internal fun isSteamLibraryFolder(name: String): Boolean =
name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR
@@ -173,6 +192,7 @@ object AddedGames {
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
val removed = SessionPrefs.removedAddedGames(context).toSet()
val entries = entryFolders(context)
for (dir in folders.distinctBy { it.absolutePath }) {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
@@ -180,7 +200,7 @@ object AddedGames {
if (folder.name.lowercase() in steamInstalls || isSteamLibraryFolder(folder.name)) continue
if (folder.path in removed) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
for (game in expand(folder, entries)) if (game.path !in removed) scanGame(context, game, out)
}
}
for (entry in AddedExes.list(context)) {
@@ -232,8 +252,9 @@ object AddedGames {
val entries = AddedExes.list(context)
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter { owns(it) && groupGames(it) == null }.maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val removed = SessionPrefs.removedAddedGames(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
GameStorage.effective(context)?.let { File(it.path) },
@@ -242,7 +263,10 @@ object AddedGames {
val base = canonical(dir)
if (!inside(path, base)) continue
val first = path.removePrefix("$base/").substringBefore('/')
File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) }?.let { return it }
val top = File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) && it.path !in removed } ?: continue
val group = groupGames(top) ?: return top
if (top.path in entries.map { it.folder }) return null
return group.firstOrNull(::holds)
}
return null
}
@@ -295,10 +319,11 @@ object AddedGames {
if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
val entries = entryFolders(app)
var already = 0
val fresh = ArrayList<String>()
val added = ArrayList<Game>()
for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty()) {
for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty().flatMap { expand(it, entries) }) {
val path = canonical(sub)
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
onChecking(sub.name)
@@ -332,6 +357,7 @@ object AddedGames {
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
val entries = entryFolders(context)
GamesFolderMigration.run(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
@@ -341,7 +367,7 @@ object AddedGames {
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
if (folder.path in removed || isSteamLibraryFolder(folder.name)) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
out.add(canonical(folder))
expand(folder, entries).filter { it.path !in removed }.forEach { out.add(canonical(it)) }
}
}
AddedExes.list(context).filter { it.folder !in removed }.forEach { out.add(canonical(File(it.folder))) }
@@ -89,7 +89,13 @@ object AutoComponents {
private fun enqueue(context: Context, job: Job) {
if (!queuedKeys.add(job.key)) {
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
synchronized(jobs) {
val old = jobs.firstOrNull { it.key == job.key }
if (old != null && ((job.asked && !old.asked) || (job.inSession && !old.inSession)) && jobs.remove(old)) {
jobs.add(Job(old.game ?: job.game, old.component, old.asked || job.asked,
if (job.asked && !old.asked) job.seq else old.seq, old.pics ?: job.pics, old.inSession || job.inSession))
}
}
if (runNow && !draining) drain(context)
return
}
@@ -105,10 +111,7 @@ object AutoComponents {
@VisibleForTesting internal var gameRunning: () -> Boolean = { gameProcessRunning() }
private fun gameProcessRunning(): Boolean = File("/proc").listFiles()?.any { p ->
p.name.all(Char::isDigit) && runCatching { String(File(p, "cmdline").readBytes()).replace('\u0000', ' ') }.getOrNull()
?.let { it.contains("SteamLaunch AppId=") || it.contains("proton waitforexitandrun") } == true
} == true
private fun gameProcessRunning(): Boolean = com.droiddeck.launcher.session.SteamGameProcess.running()
fun queueInSession(context: Context, game: Library.SteamGame) {
if (appKey(game) == null) return
@@ -185,18 +188,19 @@ object AutoComponents {
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return
val wanted = autoPicks(findings, catalog)
val user = WinComponents.selection(context, key).user
val before = WinComponents.selection(context, key).auto
val installed = installedOf(context)
val kept = LinkedHashMap<String, WinComponents.AutoReason>()
val missing = ArrayList<String>()
for ((id, reason) in wanted) {
if (user[id] == false) { kept[id] = reason; continue }
if (id in installed) { kept[id] = reason; continue }
if (id in before) kept[id] = reason
synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } }
queueDownload(context, id, asked, inSession)
missing.add(id)
}
if (inSession) Pending.set(context, key, missing)
val before = WinComponents.selection(context, key).auto
if (inSession) Pending.set(context, key, (Pending.ids(context, key).filter { it !in installed && it !in wanted } + missing).distinct())
if (before.keys != kept.keys) {
WinComponents.setAuto(context, key, kept)
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
@@ -235,6 +239,16 @@ object AutoComponents {
write(context, games)
}
@Synchronized fun ids(context: Context, key: String): List<String> =
read(context).optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
@Synchronized fun add(context: Context, key: String, ids: List<String>) {
val games = read(context)
val have = games.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
games.put(key, JSONArray((have + ids).distinct()))
write(context, games)
}
@Synchronized fun drop(context: Context, id: String) {
val games = read(context)
for (k in games.keys().asSequence().toList()) {
@@ -317,6 +331,32 @@ object AutoComponents {
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
fun restoreMissing(context: Context): List<String> {
val selections = WinComponents.selections(context)
if (selections.isEmpty()) return emptyList()
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return emptyList()
val installed = installedOf(context)
val lostFor = selections.mapValues { (_, selection) ->
withParts(selection.effective.filter { WinComponents.validId(it) }, catalog).filter { it !in installed && it in catalog }
}.filterValues { it.isNotEmpty() }
if (lostFor.isEmpty()) return emptyList()
val lost = lostFor.values.flatten().distinct()
Log.i(TAG, "wincomp: reinstalling ${lost.joinToString()}, picked for games but missing from the runtime")
lostFor.forEach { (key, ids) -> Pending.add(context, key, ids) }
lost.forEach { queueDownload(context, it, asked = true, inSession = true) }
return lost
}
private fun withParts(ids: List<String>, catalog: Map<String, WinComponents.Component>): List<String> {
val out = LinkedHashSet<String>()
fun visit(id: String, depth: Int) {
if (depth > 8 || !out.add(id)) return
catalog[id]?.dependencies?.forEach { visit(it, depth + 1) }
}
ids.forEach { visit(it, 0) }
return out.toList()
}
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
fun sweep(context: Context, games: List<Library.SteamGame>) {
@@ -89,6 +89,8 @@ object GameExePicker {
return score
}
internal fun auxiliaryDir(dir: File): Boolean = skipDir(dir, dir.name)
private fun skipDir(dir: File, relative: String): Boolean {
val name = dir.name.lowercase().trim()
if (name in SKIP_DIRS || SKIP_DIR_PREFIX.any { name.startsWith(it) }) return true
@@ -24,15 +24,33 @@ object GamesFolderMigration {
for (root in roots) {
var n = 0
val steamInstalls = AddedGames.steamInstallDirs(root.host)
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (AddedGames.isSteamLibraryFolder(folder.name) || folder.name.lowercase() in steamInstalls) continue
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
entries.add(folder.path)
if (folder.path !in removed) n++
for (top in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (AddedGames.isSteamLibraryFolder(top.name) || top.name.lowercase() in steamInstalls) continue
if (top.absolutePath in storeRoots || StoreGameSidecar.read(top) != null) continue
if (top.path in entries) continue
val topPick = SessionPrefs.addedGameExe(context, top.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val groups = AddedGames.groupGames(top)
val played = topPick ?: groups?.let { AddedGames.rank(top).exes.firstOrNull() }
val owner = played?.let { exe -> top.listFiles { f -> f.isDirectory }?.firstOrNull { exe.path.startsWith(it.path + File.separator) } }
val folders = when {
groups == null -> listOf(top)
topPick == null || owner in groups -> groups
owner == null || owner.name.startsWith(".") || AddedGames.isSteamLibraryFolder(owner.name) -> listOf(top)
else -> (groups + owner).sortedBy { it.name.lowercase() }
}
for (folder in folders) {
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
?: topPick?.takeIf { it.path.startsWith(folder.path + File.separator) }
val ranked = AddedGames.rank(folder).exes.firstOrNull()
val exe = picked ?: played?.takeIf { folder == owner } ?: ranked ?: continue
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null || exe != ranked))
entries.add(folder.path)
if (folder != top && folder == owner) SessionPrefs.inheritAddedGame(context, top.path, folder.path)
if (folder != top && top.path in removed) SessionPrefs.setAddedGameRemoved(context, folder.path, true)
else if (folder.path !in removed) n++
}
}
Log.i(TAG, "games: migrated ${root.host} → $n games")
}
@@ -274,7 +274,7 @@ object Library {
Spec("duckstation", "DuckStation", "PS1", "/opt/appimages/duckstation.AppImage", listOf("ps1", "psx"), setOf("cue", "chd", "pbp", "iso", "bin", "img", "ecm", "m3u")),
Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi"), atPanel = true),
Spec("cemu", "Cemu", "Wii U", "/opt/appimages/cemu.AppImage", listOf("wiiu", "wii u"), setOf("wua", "wud", "wux", "rpx")),
Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")),
Spec("ppsspp", "PPSSPP", "PSP", "/usr/local/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")),
Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()),
)
private fun displayTitle(name: String): String =
@@ -16,6 +16,7 @@ data class GpuInfo(
val modelSource: String = "",
val kgslChipId: String = "",
val platformGpuModel: Int = 0,
val verifiedDevice: Boolean = false,
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
@@ -36,6 +37,7 @@ data class GpuInfo(
get() = when {
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
isA850 -> Support.UNTESTED
verifiedDevice -> Support.TESTED
family == Family.A6XX && model == 650 -> Support.TESTED
family == Family.A8XX -> Support.TESTED
family == Family.A7XX && model >= 725 -> Support.TESTED
@@ -85,9 +87,13 @@ data class GpuInfo(
oneUi8Gen2 = samsung && model == 740,
kgslName = raw.orEmpty(), modelSource = source, kgslChipId = chipId,
platformGpuModel = platformGpuModel,
verifiedDevice = adreno && verifiedOn(model, Build.MODEL),
)
}
internal fun verifiedOn(model: Int, device: String): Boolean =
model == 722 && device.equals("Retroid Pocket Duo", ignoreCase = true)
internal fun threeDigits(text: String): Int = Regex("""(?i)\badreno\s*(?:\(TM\)\s*)?(\d{3})(?!\d)""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
internal fun modelFromNames(
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.hud
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
/** artwork/droiddeck-mark.svg: the split D with its circular cut-out and the blue orb. */
internal object DroidDeckMark {
private const val VIEW_X = -55.98f
const val VIEW_W = 239.92f
const val VIEW_H = 223.72f
private val body: Path by lazy {
val d = Path().apply {
moveTo(-31.98f, 0f)
lineTo(63.97f, 0f)
cubicTo(125.81f, 0f, 175.93f, 50.08f, 175.93f, 111.86f)
cubicTo(175.93f, 173.64f, 125.81f, 223.72f, 63.97f, 223.72f)
lineTo(-31.98f, 223.72f)
close()
}
val cut = Path().apply {
addCircle(63.98f, 111.86f, 71.81f, Path.Direction.CW)
addRect(56.055f, 0f, 71.885f, 45f, Path.Direction.CW)
addRect(56.055f, 178.72f, 71.885f, 223.72f, Path.Direction.CW)
}
d.op(cut, Path.Op.DIFFERENCE)
d.offset(-VIEW_X, 0f)
d
}
private val scaled = Path()
private val matrix = Matrix()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
fun widthFor(height: Float): Float = height * VIEW_W / VIEW_H
/** Where the drawn mark starts and ends inside [widthFor]: the artwork has empty space on both sides. */
fun inkLeftFor(height: Float): Float = (-31.98f - VIEW_X) * height / VIEW_H
fun inkRightFor(height: Float): Float = (175.93f - VIEW_X) * height / VIEW_H
fun draw(canvas: Canvas, left: Float, top: Float, height: Float, bodyColor: Int, orbColor: Int) {
val s = height / VIEW_H
matrix.setScale(s, s)
matrix.postTranslate(left, top)
body.transform(matrix, scaled)
paint.color = bodyColor
canvas.drawPath(scaled, paint)
paint.color = orbColor
canvas.drawCircle(left + (63.98f - VIEW_X) * s, top + 111.86f * s, 55.98f * s, paint)
}
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.hud
import android.content.Context
import com.droiddeck.launcher.session.SessionPrefs
/** FusionHUD's settings as one "key=value,…" string (FusionHudView.applyConfig), stored in SessionPrefs. */
internal object FusionHudConfig {
/**
* Bannerlator's Fusion defaults (DeckOverlaySettings), with the FPS graph from the old DroidDeck HUD. The HUD starts
* locked, so like the old HUD it lets touches through until it is unlocked to move it.
*/
val DEFAULTS: Map<String, String> = linkedMapOf(
"hudSize" to "minimal", "hudLocked" to "1", "hudScale" to "100", "hudOpacity" to "80", "hudColor" to "mid",
"hudOutline" to "40", "hudOutlineAccent" to "1",
"showFPS" to "1", "showFPSGraph" to "1", "showCPUUsage" to "1", "showCPULoad" to "1", "showGPULoad" to "1",
"showVram" to "1", "showRAM" to "1", "showPower" to "1", "showTemp" to "1", "showBatteryTemp" to "1",
"showGpuTemp" to "0", "showBattery" to "0", "showLow001" to "1", "fpsDecimal" to "1", "showPerCore" to "1",
"showSwap" to "1", "showNet" to "1", "showResolution" to "1", "showProton" to "1", "showSession" to "1",
"showClock" to "1", "showEngine" to "1", "showRenderer" to "1", "showGpuModel" to "1",
"tempUnit" to "c", "tempBands" to "1", "tempAuto" to "1", "tempRedCpu" to "90", "tempRedGpu" to "90", "tempRedBat" to "48",
)
/** Switches Bannerlator sets together (DeckOverlaySettings twins). */
private val TWINS = mapOf("showCPUUsage" to "showCPULoad", "showTemp" to "showBatteryTemp", "showEngine" to "showRenderer")
fun load(context: Context): MutableMap<String, String> {
val values = LinkedHashMap(DEFAULTS)
val stored = SessionPrefs.fusionHudConfig(context)
if (stored == null) {
values["showFPS"] = flag(SessionPrefs.hudShowFps(context))
values["showFPSGraph"] = flag(SessionPrefs.hudShowGraph(context))
values["hudOpacity"] = SessionPrefs.hudOpacity(context).toString()
} else {
for (entry in KeyValueSet(stored)) if (entry.size == 2 && entry[0] in DEFAULTS) values[entry[0]] = entry[1]
}
return values
}
fun encode(values: Map<String, String>): String = values.entries.joinToString(",") { "${it.key}=${it.value}" }
fun set(context: Context, key: String, value: String): Map<String, String> {
val values = load(context)
if (key !in DEFAULTS) return values
values[key] = value
TWINS[key]?.let { values[it] = value }
SessionPrefs.setFusionHudConfig(context, encode(values))
return values
}
fun reset(context: Context): Map<String, String> {
SessionPrefs.setFusionHudConfig(context, encode(DEFAULTS))
return DEFAULTS
}
private fun flag(on: Boolean) = if (on) "1" else "0"
}
@@ -0,0 +1,236 @@
package com.droiddeck.launcher.hud
import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import kotlin.math.roundToInt
/**
* FusionHUD on the session screen, in place of the old text HUD: the view, the frame feed from gamescope
* (MangoHud's frame times) and MangoHud's readings, started with the session's first frame.
*/
class FusionHudHost(context: Context) {
private val context = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val feed = GameFrameFeed(File(LinuxRuntime.rootDir(this.context), HudFrames.PATH))
private val readings = MangoReadings(File(this.context.filesDir, "session"), LinuxRuntime.rootDir(this.context))
private var view: FusionHudView? = null
private var parent: ViewGroup? = null
private var running = false
private var hidden = false
private var backgrounded = false
private var polling = false
private var wantX = Float.NaN
private var wantY = Float.NaN
private var engine = ""
private var gpuName = ""
private var protonPid = 0
private var proton = ""
@Volatile private var lastWindow: String? = null
var onPresentingWindowChanged: (() -> Unit)? = null
/** The HUD itself changed a setting: a tap cycles the size, a long press locks it. */
var onConfigChanged: (() -> Unit)? = null
/** Screen point → whether an on-screen control takes a touch there; a locked HUD lets those through. */
var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null
private val listener = object : WaylandCompositor.GameListener {
override fun onGameSurface(window: String?, gpuName: String?) {
if (!gpuName.isNullOrBlank()) handler.post { this@FusionHudHost.gpuName = cleanGpuName(gpuName) }
if (window != null && window != lastWindow) {
lastWindow = window
handler.post { onPresentingWindowChanged?.invoke() }
}
}
override fun onGameFrame() {
feed.onCompositorFrame(System.nanoTime())
}
}
private val frameCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!running || !polling) return
if (feed.poll(System.nanoTime())) view?.onFrames()
Choreographer.getInstance().postFrameCallback(this)
}
}
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context) &&
!File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
/** Adds the HUD to [root] at [index] (above the game surface, under the session overlay). */
fun attach(root: FrameLayout, index: Int) {
if (view != null) return
val hud = FusionHudView(root.context)
hud.setFrameStats(feed.stats)
hud.setMangoReadings(readings)
hud.setGpuModel(cleanGpuName(GpuInfo.detect().displayName(context)))
hud.onRefresh = Runnable { refreshLabels(hud) }
hud.setOnMovedListener { x, y ->
wantX = x; wantY = y
keepOnScreen(hud)
wantX = hud.x; wantY = hud.y
SessionPrefs.setFusionHudPosition(context, hud.x, hud.y)
}
hud.setOnSizeCycledListener { size -> FusionHudConfig.set(context, "hudSize", size); onConfigChanged?.invoke() }
hud.setOnLockChangedListener { locked ->
FusionHudConfig.set(context, "hudLocked", if (locked) "1" else "0")
onConfigChanged?.invoke()
}
hud.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (!hud.isDragging) keepOnScreen(hud) }
hud.passesTouchAt = { x, y -> passesTouchAt?.invoke(x, y) == true }
hud.visibility = View.GONE
val margin = (16 * root.resources.displayMetrics.density).roundToInt()
root.addView(hud, index, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = margin
topMargin = margin
})
SessionPrefs.fusionHudPosition(context)?.let { (x, y) -> wantX = x; wantY = y; hud.post { keepOnScreen(hud) } }
view = hud
parent = root
applyConfig()
}
fun applyConfig() {
view?.applyConfig(FusionHudConfig.encode(FusionHudConfig.load(context)))
}
fun refresh() {
applyConfig()
if (!enabled) stop() else if (!running) start()
}
fun start() {
if (running || !enabled || view == null) return
running = true
WaylandCompositor.setGameListener(listener)
applyShown()
}
fun stop() {
running = false
showing = false
polling = false
Choreographer.getInstance().removeFrameCallback(frameCallback)
WaylandCompositor.clearGameListener(listener)
feed.close()
view?.visibility = View.GONE
}
/** Picture-in-picture and similar: keep running, draw nothing. */
fun setHidden(hide: Boolean) {
hidden = hide
applyShown()
}
/** The session screen is stopped (another app in front): draw nothing and stop reading sensors. */
fun setBackgrounded(stopped: Boolean) {
backgrounded = stopped
applyShown()
}
private fun applyShown() {
if (!running) return
val show = !hidden && !backgrounded
view?.visibility = if (show) View.VISIBLE else View.GONE
showing = show
// Frames are read once per display frame only while the HUD is drawn.
if (show != polling) {
polling = show
if (show) Choreographer.getInstance().postFrameCallback(frameCallback)
else Choreographer.getInstance().removeFrameCallback(frameCallback)
}
}
/** A sensor report (FusionHUD's diagnostics) in Downloads/DroidDeck; null when it can't be written. */
fun exportDiagnostics(): File? = runCatching {
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck")
dir.mkdirs()
val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date())
val file = File(dir, "HUD-diagnostics-$stamp.txt")
// Its own reader: the HUD's CPU load is the change since its previous sample, which an export must
// not move. Two samples half a second apart, MangoHud's sampling period.
val metrics = HudMetrics(context)
metrics.setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context)))
metrics.snapshot()
Thread.sleep(500)
val mango = metrics.snapshot()
file.writeText(metrics.also { it.setMangoReadings(null) }.buildDiagnosticsReport(context) + "\n== As the HUD shows them (MangoHud's sources) ==\n" +
"CPU ${mango.cpuPercent}% ${mango.cpuTempC}°C, GPU ${mango.gpuPercent}% ${mango.gpuClockMhz}MHz ${mango.gpuTempC}°C, " +
"RAM ${mango.ramUsedText()}/${mango.ramTotalText()}, VRAM ${mango.vramText()}, battery ${mango.battery.percent}% " +
"${mango.battery.watts}W charging=${mango.battery.charging}\n" +
"CPU temperature zones: ${LinuxRuntime.cpuTempZones().joinToString { it.name }}\n" +
"Frames: ${if (feed.engine.isNotEmpty() || feed.pid > 0) "gamescope (pid ${feed.pid}, ${feed.engine})" else "compositor"}, " +
"fps ${feed.stats.fps}\n")
file
}.getOrNull()
private fun refreshLabels(hud: FusionHudView) {
if (feed.engine.isNotBlank() && !feed.steamFocused) engine = feed.engine
hud.setEngineLabel(engine)
if (gpuName.isNotBlank()) hud.setGpuModel(gpuName)
hud.setResolution(if (feed.outputWidth > 0 && feed.outputHeight > 0) buildString {
append(feed.outputWidth).append('x').append(feed.outputHeight)
if (feed.refreshMilliHz > 0) append(" @ ").append((feed.refreshMilliHz + 499) / 1000).append("Hz")
} else "")
val pid = feed.pid
if (pid > 0 && pid != protonPid) {
protonPid = pid
proton = protonName(pid) ?: proton
}
hud.setWineVersion(proton)
hud.setPresentedFps(presentedFps())
}
private fun presentedFps(): Float {
if (FrameGen.mode(context).engine == FrameGen.ENGINE_OFF) return 0f
val stats = runCatching { WaylandCompositor.nativeFrameGenStats() }.getOrNull() ?: return 0f
return if (stats.size > 3 && stats[3] > 1f) stats[3] else 0f
}
// The HUD is clamped from where the user left it (wantX/wantY, set on a drop or restore), not from where
// it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the
// corner the small window pushed it to.
private fun keepOnScreen(v: View) {
val p = v.parent as? View ?: return
if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return
if (wantX.isNaN()) { wantX = v.x; wantY = v.y }
val maxX = (p.width - v.width).toFloat().coerceAtLeast(0f)
val maxY = (p.height - v.height).toFloat().coerceAtLeast(0f)
val x = wantX.coerceIn(0f, maxX)
val y = wantY.coerceIn(0f, maxY)
if (v.x != x) v.x = x
if (v.y != y) v.y = y
}
companion object {
/** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */
@Volatile @JvmStatic var showing = false
private set
/** "Adreno (TM) 750" as the driver names it, "Adreno 750" as DroidDeck does elsewhere. */
internal fun cleanGpuName(name: String): String = name.replace("(TM)", "").replace(Regex("\\s+"), " ").trim()
/** The Proton build of a game process: the first STEAM_COMPAT_TOOL_PATHS entry's folder. */
internal fun protonName(pid: Int): String? = runCatching {
val environ = File("/proc/$pid/environ").readBytes().toString(Charsets.UTF_8).split('\u0000')
val paths = environ.firstOrNull { it.startsWith("STEAM_COMPAT_TOOL_PATHS=") }?.substringAfter('=') ?: return null
paths.split(':').firstOrNull { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')
}.getOrNull()?.takeIf { it.isNotBlank() }
}
}
@@ -0,0 +1,86 @@
/*
* FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays.
* Copyright (C) The412Banner <https://github.com/The412Banner>
*
* Licensed under the GNU General Public License v3.0 (see LICENSE), with an
* ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md):
* any use, fork, or distribution must preserve credit to "The412Banner" and a
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
*/
package com.droiddeck.launcher.hud
/**
* Size modes for the Fusion HUD (the 4th selectable in-game overlay style). One shared color-coded
* visual language, four amounts of detail — see the approved mockup. The config value is the lower-case
* token stored under the `hudSize` key; the label is the user-facing string.
*/
enum class FusionSize(val token: String, val label: String) {
FULL("full", "Full"),
TILES("tiles", "Tiles"),
PILL("pill", "Pill"),
MINIMAL("minimal", "Minimal"),
MEGA("mega", "Mega");
/** Next size in the tap-cycle: Full → Tiles → Pill → Minimal → Mega → Full. */
fun next(): FusionSize {
val all = values()
return all[(ordinal + 1) % all.size]
}
/**
* The metric toggle chips this size actually renders — the SINGLE source of truth queried by BOTH
* config surfaces ({@code FpsCounterConfigDialog} + in-game {@code XServerDrawer} HudContent) so the
* chip list can't drift from what the view draws. Chip ids are the chip LABELS used in the UI.
* (Hiding a chip is purely UI; buildConfig() still emits every key — strip-invariant.)
*/
fun supportedChips(): Set<String> = when (this) {
MINIMAL -> MINIMAL_CHIPS
PILL -> PILL_CHIPS
FULL, TILES -> STANDARD_CHIPS
MEGA -> MEGA_CHIPS
}
companion object {
fun from(token: String?): FusionSize =
values().firstOrNull { it.token.equals(token, ignoreCase = true) } ?: FULL
// Chip-label ids (must match the labels the config UIs build). "Clock" is a small subtle
// corner readout available in EVERY size, so it's in all sets.
// The API/engine label ("Engine" chip) shows on the FPS row in EVERY size, so it's in all sets.
// "Wrapper" and "DX ver" are Mega-only: the wrapper appears solely below Mega's frametime graph,
// and "DX ver" gates the DX-version suffix on Mega's FPS-row engine label.
val MINIMAL_CHIPS = setOf("FPS", "FPS graph", "0.01% low", "FPS .1", "Engine", "Clock")
val PILL_CHIPS = setOf("FPS", "FPS graph", "GPU", "CPU", "VRAM", "RAM", "Battery", "GPU model", "FPS .1", "Engine", "Clock")
val STANDARD_CHIPS = setOf(
"FPS", "FPS graph", "GPU", "GPU temp", "CPU", "VRAM", "RAM", "Power", "Temp",
"Battery", "GPU model", "Engine", "0.01% low", "FPS .1", "Clock",
)
val MEGA_ONLY = setOf(
"Per-core", "Swap", "Network", "Resolution", "Proton", "Wrapper", "DX ver", "Session",
)
val MEGA_CHIPS = STANDARD_CHIPS + MEGA_ONLY
}
}
/**
* The Wayland HDR state the Fusion HUD shows on its own line, directly under the latency · display-server
* line (the host sets it with [FusionHudView.setHdrState]). [NONE] = no line at all: every session whose
* HDR gate is closed (X11, SDR screens, HDR off) keeps the HUD exactly as it was.
*/
object FusionHdr {
const val NONE = 0
const val ON = 1 // HDR frames on screen with HDR headroom
const val NO_HEADROOM = 2 // HDR frames on screen, but the display gives them no headroom (5 s+)
const val OFF = 3 // the drawer's HDR output switch is off (tone-mapped to SDR)
const val READY = 4 // HDR open for the session, no HDR frames on screen right now
const val TONEMAPPED = 5 // HDR output on, but the frames are shown tone-mapped (frame generation
// on a screen that offers no HDR10 swapchain)
const val NOT_ON_THIS_SCREEN = 6 // HDR open for the session, but the screen the game is on NOW
// reports no HDR10 (the TV was unplugged mid-game). The offer to the
// game cannot be withdrawn once made, so the gate stays open for the
// session — but "ready" would be a lie on a panel that cannot show it.
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,262 @@
package com.droiddeck.launcher.hud
import java.io.File
import kotlin.math.roundToInt
/**
* Frame statistics by MangoHud's rules, so the HUD and Steam's performance overlay agree: MangoHud
* overlay.cpp update_hud_info_with_frametime and hud_elements.cpp fps() / frame_timing(). The FPS is the
* frames counted over a sampling period of at least 500 ms, the frame time next to it is 1000 / FPS, and
* the graph holds the last 200 frame times (zeros until filled) on a fixed 0-50 ms scale, min and max
* taken over those 200. Average and lows are FusionHUD's own readouts.
*/
internal class FrameStats {
private val graph = FloatArray(GRAPH_FRAMES)
private var graphHead = 0
private var lastPresentNs = 0L
private var lastFpsUpdateNs = 0L
private var framesSinceUpdate = 0
private val lowsRing = LongArray(LOWS_FRAMES)
private var lowsHead = 0
private var lowsSize = 0
private var cachedLows = Lows.EMPTY
private var cachedLowsNs = 0L
private var sessionStartNs = 0L
private var sessionFrames = 0L
@Volatile var fps = 0f
private set
/** Completed sampling periods; MangoHud redraws its readouts when one completes. */
@Volatile var fpsUpdates = 0L
private set
/** Frames added so far; the HUD redraws its graph when this moves. */
@Volatile var count = 0L
private set
@Synchronized
fun add(frametimeNs: Long, timeNs: Long) {
if (frametimeNs <= 0L) return
if (lastPresentNs != 0L) {
graph[graphHead] = frametimeNs / 1e6f
graphHead = (graphHead + 1) % GRAPH_FRAMES
}
val elapsed = timeNs - lastFpsUpdateNs
if (elapsed >= FPS_PERIOD_NS) {
fps = (1e9 * framesSinceUpdate / elapsed).toFloat()
framesSinceUpdate = 0
lastFpsUpdateNs = timeNs
fpsUpdates++
}
lastPresentNs = timeNs
framesSinceUpdate++
if (frametimeNs < PAUSE_NS) {
lowsRing[lowsHead] = frametimeNs
lowsHead = (lowsHead + 1) % LOWS_FRAMES
if (lowsSize < LOWS_FRAMES) lowsSize++
if (sessionStartNs == 0L) sessionStartNs = timeNs - frametimeNs
sessionFrames++
}
count++
}
@Synchronized
fun reset() {
graph.fill(0f)
graphHead = 0
lastPresentNs = 0L
lastFpsUpdateNs = 0L
framesSinceUpdate = 0
lowsHead = 0
lowsSize = 0
cachedLows = Lows.EMPTY
cachedLowsNs = 0L
sessionStartNs = 0L
sessionFrames = 0L
fps = 0f
count++
}
/** MangoHud's frame time readout: 1000 / FPS. */
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
/** The graph, oldest first, into [out] (GRAPH_FRAMES long). */
@Synchronized
fun graph(out: FloatArray) {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
}
@Synchronized
fun graphMinMs(): Float = graph.min()
@Synchronized
fun graphMaxMs(): Float = graph.max()
/** Average over the frames this session, from the first frame to [nowNs]'s last frame. */
@Synchronized
fun averageFps(): Float {
if (sessionFrames < 2 || lastPresentNs <= sessionStartNs) return 0f
return (1e9 * sessionFrames / (lastPresentNs - sessionStartNs)).toFloat()
}
@Synchronized
fun sessionSeconds(nowNs: Long): Float = if (sessionStartNs == 0L) 0f else (nowNs - sessionStartNs) / 1e9f
class Lows(val low1Fps: Float, val low01Fps: Float, val low001Fps: Float) {
companion object {
val EMPTY = Lows(0f, 0f, 0f)
}
}
/** FPS at the 99th, 99.9th and 99.99th percentile frame time of the last LOWS_FRAMES frames. */
fun lows(nowNs: Long): Lows {
val copy: LongArray
synchronized(this) {
if (cachedLows !== Lows.EMPTY && nowNs - cachedLowsNs < LOWS_RECOMPUTE_NS) return cachedLows
if (lowsSize < 2) return Lows.EMPTY
copy = LongArray(lowsSize)
val start = (lowsHead - lowsSize + LOWS_FRAMES) % LOWS_FRAMES
for (i in 0 until lowsSize) copy[i] = lowsRing[(start + i) % LOWS_FRAMES]
}
copy.sort()
fun at(p: Double): Float {
val ft = copy[(p * (copy.size - 1)).roundToInt().coerceIn(0, copy.size - 1)]
return if (ft > 0) (1e9 / ft).toFloat() else 0f
}
val result = Lows(at(0.99), at(0.999), at(0.9999))
synchronized(this) {
cachedLows = result
cachedLowsNs = nowNs
}
return result
}
companion object {
const val GRAPH_FRAMES = 200
const val GRAPH_MAX_MS = 50f
const val FPS_PERIOD_NS = 500_000_000L
const val LOWS_FRAMES = 8192
private const val PAUSE_NS = 1_000_000_000L
private const val LOWS_RECOMPUTE_NS = 250_000_000L
}
}
/**
* Feeds [stats] with the frame times gamescope shares (the ones MangoHud shows). Sessions without the
* shared file, or where gamescope stopped sending, fall back to the frames DroidDeck's compositor shows,
* which stop at the screen's refresh rate.
*/
internal class GameFrameFeed(private val file: File, val stats: FrameStats = FrameStats()) {
private var handle = 0L
private var nextOpenNs = 0L
private var lastRingFrameNs = 0L
private var lastInfoNs = 0L
private val frametimes = LongArray(HudFrames.CAPACITY)
private val times = LongArray(HudFrames.CAPACITY)
private val info = IntArray(5)
@Volatile private var fromGamescope = false
private var lastCompositorNs = 0L
@Volatile private var compositorFrames = 0L // written by the compositor thread only
private var seenCompositorFrames = 0L
@Volatile var engine = ""
private set
@Volatile var steamFocused = false
private set
@Volatile var pid = 0
private set
@Volatile var outputWidth = 0
private set
@Volatile var outputHeight = 0
private set
@Volatile var refreshMilliHz = 0
private set
/** Main thread, once per display frame. True when new frames arrived. */
fun poll(nowNs: Long): Boolean {
val compositorAdded = compositorFrames != seenCompositorFrames
seenCompositorFrames = compositorFrames
if (handle == 0L) {
if (nowNs < nextOpenNs) return compositorAdded
nextOpenNs = nowNs + REOPEN_NS
handle = runCatching { HudFrames.nativeOpen(file.path) }.getOrDefault(0L)
if (handle == 0L) return compositorAdded
// Frames already in the file were written before this reader started.
HudFrames.nativePoll(handle, frametimes, times)
}
val n = HudFrames.nativePoll(handle, frametimes, times)
if (n > 0) {
if (!fromGamescope) {
stats.reset()
fromGamescope = true
}
for (i in 0 until n) stats.add(frametimes[i], times[i])
lastRingFrameNs = times[n - 1]
} else if (lastRingFrameNs != 0L && nowNs - lastRingFrameNs > LIVE_NS) {
fromGamescope = false
}
// A new session writes the file again from the start; reopen when gamescope has gone quiet.
if (n <= 0 && nowNs - maxOf(lastRingFrameNs, nextOpenNs - REOPEN_NS) > STALE_NS) close()
if (nowNs - lastInfoNs >= INFO_NS) {
lastInfoNs = nowNs
readInfo()
}
return n > 0 || compositorAdded
}
/** Compositor thread: one frame shown on the panel. */
fun onCompositorFrame(nowNs: Long) {
if (fromGamescope) {
lastCompositorNs = 0L
return
}
val last = lastCompositorNs
lastCompositorNs = nowNs
if (last != 0L) {
stats.add(nowNs - last, nowNs)
compositorFrames++
}
}
fun close() {
if (handle != 0L) HudFrames.nativeClose(handle)
handle = 0L
}
private fun readInfo() {
val name = if (handle != 0L && fromGamescope) HudFrames.nativeInfo(handle, info) else null
if (name == null) {
engine = ""
return
}
pid = info[0]
outputWidth = info[1]
outputHeight = info[2]
refreshMilliHz = info[3]
steamFocused = info[4] != 0
engine = engineLabel(name)
}
companion object {
private const val REOPEN_NS = 2_000_000_000L
private const val LIVE_NS = 2_000_000_000L
private const val STALE_NS = 5_000_000_000L
private const val INFO_NS = 500_000_000L
/** DroidDeck's mangoapp's names for gamescope's engine field (Valve's 20260902.1 build, app/main.cpp
* engine_type_from_str and overlay.h engines); gamescope sends "gamescope" when it knows none. */
fun engineLabel(name: String): String = when (name) {
"DXVK" -> "DXVK"
"vkd3d" -> "VKD3D"
"mesa zink" -> "ZINK"
"Damavand" -> "DAMAVAND"
"Feral3D" -> "Feral3D"
"gamescope", "" -> "GAMESCOPE"
else -> "VULKAN"
}
}
}
@@ -0,0 +1,17 @@
package com.droiddeck.launcher.hud
/** Reader for the frame-times file gamescope patch 0125 writes (hudframes.c). */
internal object HudFrames {
init {
System.loadLibrary("droiddeckwayland")
}
/** Under the Linux root: the session exports /run/droiddeck-hud/frames to gamescope. */
const val PATH = "run/droiddeck-hud/frames"
const val CAPACITY = 1024
@JvmStatic external fun nativeOpen(path: String): Long
@JvmStatic external fun nativeClose(handle: Long)
@JvmStatic external fun nativePoll(handle: Long, frametimes: LongArray, times: LongArray): Int
@JvmStatic external fun nativeInfo(handle: Long, info: IntArray): String?
}
@@ -0,0 +1,265 @@
/*
* FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays.
* Copyright (C) The412Banner <https://github.com/The412Banner>
*
* Licensed under the GNU General Public License v3.0 (see LICENSE), with an
* ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md):
* any use, fork, or distribution must preserve credit to "The412Banner" and a
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
*/
package com.droiddeck.launcher.hud;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.SystemClock;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
/**
* Shared touch + lock behaviour for every in-game performance HUD (classic FrameRating(+Horizontal),
* GameHub PerfHudView, GameNative PerformanceHudView, and the Fusion HUD). One place so the four
* overlays can't drift:
*
* <ul>
* <li><b>tap</b> → {@link Callbacks#onTap()} (orientation-toggle for the first three; size-cycle for
* Fusion) — a no-op that only re-flashes the lock badge while locked.</li>
* <li><b>drag</b> → moves the host view and, on release, {@link Callbacks#onMoved} to persist the
* position — disabled (frozen) while locked.</li>
* <li><b>long-press</b> → toggles {@code hudLocked}, persisted via {@link Callbacks#onLockChanged}
* and kept in sync with the "Lock in place" menu chip. Works locked or unlocked (so a long-press
* always unlocks); a locked {@code FusionHudView} passes touches on only over on-screen controls.</li>
* </ul>
*
* <p>On every lock/unlock (and on a blocked gesture while locked) a small padlock badge fades in →
* holds → fades out in a corner of the panel. The fade is driven by a self-reposting Runnable that
* invalidates only while visible — nothing runs on the present path, nothing animates at rest.
*
* <p>All methods are Main-thread only (touch + draw), matching the epoll-thread rule: the views feed
* this from {@code onTouchEvent}/{@code dispatchDraw}, never from a present tick.
*/
public class HudLockController {
public interface Callbacks {
/** Unlocked single tap: orientation-toggle (classic/gamehub/gamenative) or size-cycle (fusion). */
void onTap();
/** Drag released (unlocked): persist the new (x, y). */
void onMoved(float x, float y);
/** Lock toggled by long-press: persist {@code hudLocked} + sync the menu chip. */
void onLockChanged(boolean locked);
}
// Fade timing (ms): in → hold → out. Lock/unlock hold ~1.5 s; a blocked-gesture re-flash is shorter.
private static final long FADE_IN_MS = 160;
private static final long HOLD_TOGGLE_MS = 1500;
private static final long HOLD_REFLASH_MS = 750;
private static final long FADE_OUT_MS = 420;
private static final long FRAME_MS = 32;
private final View view;
private final Callbacks cb;
private final GestureDetector detector;
private boolean locked = false;
// Drag state
private float downRawX, downRawY, startX, startY;
private boolean dragging = false;
// Fade state
private long fadeStart = 0;
private long holdMs = HOLD_TOGGLE_MS;
private boolean fadeShowLocked = false;
private final Paint iconFill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint iconStroke = new Paint(Paint.ANTI_ALIAS_FLAG);
public HudLockController(Context context, View view, Callbacks cb) {
this.view = view;
this.cb = cb;
this.detector = new GestureDetector(context, new Listener());
this.detector.setIsLongpressEnabled(true);
iconFill.setStyle(Paint.Style.FILL);
iconStroke.setStyle(Paint.Style.STROKE);
iconStroke.setStrokeCap(Paint.Cap.ROUND);
iconStroke.setStrokeJoin(Paint.Join.ROUND);
}
/** Set lock state from config (applyConfig) WITHOUT firing the persist callback. */
public void setLocked(boolean locked) { this.locked = locked; }
public boolean isLocked() { return locked; }
public boolean isDragging() { return dragging; }
/** Feed from the host view's onTouchEvent. Always consumes (mirrors the legacy manual handlers). */
public boolean onTouchEvent(MotionEvent event) {
detector.onTouchEvent(event);
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
if (dragging && !locked) cb.onMoved(view.getX(), view.getY());
dragging = false;
}
return true;
}
private final class Listener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
downRawX = e.getRawX();
downRawY = e.getRawY();
startX = view.getX();
startY = view.getY();
dragging = false;
return true;
}
@Override
public void onLongPress(MotionEvent e) {
// Long-press always toggles — this is the only gesture that still works while locked.
locked = !locked;
cb.onLockChanged(locked);
flash(locked, HOLD_TOGGLE_MS);
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
if (locked) {
flash(true, HOLD_REFLASH_MS); // intentionally locked — remind the user
} else {
cb.onTap();
}
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (locked) {
if (!dragging) flash(true, HOLD_REFLASH_MS);
dragging = true; // mark so the blocked drag doesn't fall through to a tap
return true;
}
view.setX(startX + (e2.getRawX() - downRawX));
view.setY(startY + (e2.getRawY() - downRawY));
dragging = true;
return true;
}
}
// ---- Fade badge -------------------------------------------------------
private void flash(boolean showLocked, long hold) {
fadeShowLocked = showLocked;
holdMs = hold;
fadeStart = SystemClock.elapsedRealtime();
view.removeCallbacks(fadeTick);
view.post(fadeTick);
}
private final Runnable fadeTick = new Runnable() {
@Override
public void run() {
view.invalidate();
if (currentAlpha() > 0f) view.postDelayed(this, FRAME_MS);
}
};
private float currentAlpha() {
if (fadeStart == 0) return 0f;
long e = SystemClock.elapsedRealtime() - fadeStart;
if (e < 0) return 0f;
if (e < FADE_IN_MS) return e / (float) FADE_IN_MS;
long holdEnd = FADE_IN_MS + holdMs;
if (e < holdEnd) return 1f;
long outEnd = holdEnd + FADE_OUT_MS;
if (e < outEnd) return 1f - (e - holdEnd) / (float) FADE_OUT_MS;
return 0f;
}
private static int withAlpha(int color, float a) {
return Color.argb(Math.round(255 * a), Color.red(color), Color.green(color), Color.blue(color));
}
/**
* Draw the padlock badge in the top-right of the panel if a fade is in progress. Call from the
* host's onDraw (plain View) or dispatchDraw (ViewGroup), after the content.
*
* <p>No background box: the glyph is drawn twice — a slightly expanded, thicker semi-transparent
* black "halo" pass for legibility on any game frame, then the white glyph on top — so it reads as
* a floating icon, not an opaque tile.
*/
public void drawBadge(Canvas canvas) {
float a = currentAlpha();
if (a <= 0f) return;
float d = view.getResources().getDisplayMetrics().density;
float size = 15f * d;
float margin = 4f * d;
float right = view.getWidth() - margin;
float left = right - size;
float top = margin;
float cx = (left + right) / 2f;
float bottom = top + size;
float bodyTop = top + size * 0.46f;
float bodyLeft = left + size * 0.14f;
float bodyRight = right - size * 0.14f;
float r = size * 0.2f;
float shackleTop = top + size * 0.08f;
// Pass 1: dark halo (expanded body, thicker shackle) for contrast; Pass 2: white glyph.
drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d,
Color.argb(Math.round(150 * a), 0, 0, 0), 3.0f * d, 0.9f * d, false);
drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d,
withAlpha(0xFFFFFFFF, a), 1.7f * d, 0f, true);
}
/** One padlock pass. {@code halo} inflates the body/shackle in dark; the white pass punches the keyhole. */
private void drawLock(Canvas canvas, float cx, float bodyTop, float bodyLeft, float bodyRight,
float bottom, float r, float shackleTop, float d,
int color, float strokeW, float expand, boolean punchKeyhole) {
iconFill.setColor(color);
iconStroke.setColor(color);
iconStroke.setStrokeWidth(strokeW);
// Body
canvas.drawRoundRect(bodyLeft - expand, bodyTop - expand, bodyRight + expand, bottom + expand,
2.4f * d, 2.4f * d, iconFill);
if (punchKeyhole) {
int prev = iconFill.getColor();
iconFill.setColor(Color.argb(Color.alpha(color), 0, 0, 0));
canvas.drawCircle(cx, (bodyTop + bottom) / 2f, 1.6f * d, iconFill);
iconFill.setColor(prev);
}
RectF arc = new RectF(cx - r, shackleTop, cx + r, shackleTop + 2f * r);
if (fadeShowLocked) {
// Closed: both legs run straight down into the body.
Path p = new Path();
p.addArc(arc, 180f, 180f);
p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop);
p.moveTo(cx + r, shackleTop + r); p.lineTo(cx + r, bodyTop);
canvas.drawPath(p, iconStroke);
} else {
// Open, clearly to the LEFT: the RIGHT leg stays seated in the body; the arc + LEFT leg
// hinge up-and-left off the right attachment so the left leg lifts well clear of the body.
canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke); // fixed right leg
Path p = new Path();
p.addArc(arc, 180f, 180f);
p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); // full-length left leg
canvas.save();
canvas.rotate(-42f, cx + r, shackleTop + r);
canvas.drawPath(p, iconStroke);
canvas.restore();
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,132 @@
/*
* FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays.
* Copyright (C) The412Banner <https://github.com/The412Banner>
*
* Licensed under the GNU General Public License v3.0 (see LICENSE), with an
* ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md):
* any use, fork, or distribution must preserve credit to "The412Banner" and a
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
*/
package com.droiddeck.launcher.hud;
import androidx.annotation.NonNull;
import java.util.Iterator;
public class KeyValueSet implements Iterable<String[]> {
private String data = "";
// Default constructor for an empty KeyValueSet
public KeyValueSet() {
this.data = "";
}
// Constructor to initialize with a string representation of key-value pairs
public KeyValueSet(String data) {
this.data = data != null && !data.isEmpty() ? data : "";
}
// Helper method to find the start and end indices of a key in the data string
private int[] indexOfKey(String key) {
int start = 0;
int end = data.indexOf(",");
if (end == -1) end = data.length();
while (start < end) {
int index = data.indexOf("=", start);
String currKey = data.substring(start, index);
if (currKey.equals(key)) return new int[]{start, end};
start = end + 1;
end = data.indexOf(",", start);
if (end == -1) end = data.length();
}
return null;
}
// Method to get the value associated with a key, or an empty string if the key is not present
public String get(String key) {
for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1];
return "";
}
// Method to get the value associated with a key, or a fallback value if the key is not present
public String get(String key, String fallback) {
for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1];
return fallback;
}
// Method to get a boolean value associated with a key, or a fallback value if the key is not present
public boolean getBoolean(String key, boolean fallback) {
String value = get(key);
if (value.isEmpty()) return fallback;
return value.equals("1") || value.equals("t") || value.equalsIgnoreCase("true");
}
// Method to get a float value associated with a key, or a fallback value if the key is not present
public float getFloat(String key, float fallback) {
String value = get(key);
try {
if (!value.isEmpty()) return Float.parseFloat(value);
} catch (NumberFormatException e) {
// Ignore exception and return fallback
}
return fallback;
}
// Method to set a value for a key in the key-value data
public void put(String key, Object value) {
int[] range = indexOfKey(key);
if (range != null) {
data = data.substring(0, range[0]) + key + "=" + value + data.substring(range[1]);
} else {
data = (!data.isEmpty() ? data + "," : "") + key + "=" + value;
}
}
// Method to set a float value for a key in the key-value data
public void put(String key, float value) {
put(key, String.valueOf(value));
}
// Iterator implementation for key-value pairs in the format [key, value]
@NonNull
@Override
public Iterator<String[]> iterator() {
final int[] start = {0};
final int[] end = {data.indexOf(",")};
// A single-item config (no comma) gives indexOf == -1, making hasNext() (start < end)
// immediately false so the sole pair was never iterated (get() then returned the
// fallback). Guard it exactly as next() already does. An empty string still yields
// end == 0 → no iteration, which is correct.
if (end[0] == -1) end[0] = data.length();
final String[] item = new String[2];
return new Iterator<String[]>() {
@Override
public boolean hasNext() {
return start[0] < end[0];
}
@Override
public String[] next() {
int index = data.indexOf("=", start[0]);
item[0] = data.substring(start[0], index);
item[1] = data.substring(index + 1, end[0]);
start[0] = end[0] + 1;
end[0] = data.indexOf(",", start[0]);
if (end[0] == -1) end[0] = data.length();
return item;
}
};
}
@NonNull
@Override
public String toString() {
return data;
}
}
@@ -0,0 +1,157 @@
package com.droiddeck.launcher.hud
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* The readings Steam's performance overlay shows in a DroidDeck session (Valve's mangohud 20260902.1,
* tools/mangoapp), taken from the files the session hands it and computed the same way, so the two
* overlays agree. Sources and formulas follow that build: cpu.cpp calculateCPUData, deckard_temp.cpp,
* msm.cpp getQcomGpuInfo, memory.cpp update_meminfo and battery.cpp getPercent / getPower.
*/
internal class MangoReadings(private val sessionDir: File, private val rootDir: File) {
class Cpu(val percent: Int, val perCore: IntArray)
class Memory(val usedBytes: Long, val totalBytes: Long, val swapUsedBytes: Long, val swapTotalBytes: Long)
class Battery(val percent: Int?, val watts: Float, val charging: Boolean)
private val cpuTimes = HashMap<String, LongArray>()
private val cpuPercents = HashMap<String, Int>()
/** Total and per-core load from the session's /proc/stat (DroidDeck's stand-in when the app can't read it). */
@JvmOverloads
fun cpu(stat: File = statFile()): Cpu? {
val lines = runCatching { stat.readLines() }.getOrNull() ?: return null
var total: Int? = null
val cores = ArrayList<Int>()
for (line in lines) {
if (!line.startsWith("cpu")) break
val parts = line.split(WHITESPACE)
if (parts.size < 11) continue
val values = LongArray(10) { parts[it + 1].toLongOrNull() ?: 0L }
val percent = cpuPercent(parts[0], values)
if (parts[0] == "cpu") total = percent else cores.add(percent)
}
return total?.let { Cpu(it, cores.toIntArray()) }
}
private fun statFile(): File =
File(sessionDir, "proc-stat").takeIf { !File("/proc/stat").canRead() && it.isFile } ?: File("/proc/stat")
private fun cpuPercent(key: String, v: LongArray): Int {
// user nice system idle iowait irq softirq steal guest guest_nice; guest time is already in user.
val user = v[0] - v[8]
val nice = v[1] - v[9]
val systemAll = v[2] + v[5] + v[6]
val idleAll = v[3] + v[4]
val virt = v[8] + v[9]
val totalTime = user + nice + systemAll + idleAll + v[7] + virt
val now = longArrayOf(user, nice, systemAll, v[7], virt, totalTime)
val last = cpuTimes.put(key, now) ?: LongArray(6)
fun period(i: Int) = if (now[i] > last[i]) now[i] - last[i] else 0L
val totalPeriod = period(5).toFloat()
if (totalPeriod == 0f) return cpuPercents[key] ?: 0
val busy = period(1) * 100f / totalPeriod + period(0) * 100f / totalPeriod +
period(2) * 100f / totalPeriod + (period(3) + period(4)) * 100f / totalPeriod
return busy.coerceIn(0f, 100f).toInt().also { cpuPercents[key] = it }
}
/** Average of the zones the session types cpuN-thermal, truncated. */
fun cpuTempC(): Int? {
var sum = 0f
var count = 0
for (zone in LinuxRuntime.cpuTempZones()) {
val milli = readLong(File(zone, "temp")) ?: continue
sum += milli / 1000f
count++
}
return if (count == 0) null else (sum / count).toInt()
}
fun gpuPercent(): Int? {
val feed = fresh(File(sessionDir, "sys/kgsl-3d0/perf_now"))
val source = feed ?: LinuxRuntime.gpuLoadSource()?.let(::File) ?: return null
return leadingNumber(source)?.toInt()?.coerceIn(0, 100)
}
fun gpuClockMhz(): Int? {
val feed = fresh(File(sessionDir, "sys/kgsl-3d0/cur_freq"))
val source = feed ?: listOf("$KGSL/devfreq/cur_freq", "$KGSL/gpuclk").map(::File).firstOrNull { it.canRead() } ?: return null
val hz = leadingNumber(source) ?: return null
return (hz / 1_000_000.0).toInt().takeIf { it > 0 }
}
fun gpuTempC(): Int? {
val milli = LinuxRuntime.gpuTempSource()?.let { readLong(File(it)) } ?: return null
return (milli / 1000.0).toInt()
}
/** MemTotal - MemAvailable and SwapTotal - SwapFree from /proc/meminfo. */
fun memory(): Memory? {
val kb = HashMap<String, Long>()
runCatching {
File("/proc/meminfo").forEachLine { line ->
val colon = line.indexOf(':')
if (colon > 0) line.substring(colon + 1).trim().split(WHITESPACE).firstOrNull()?.toLongOrNull()
?.let { kb[line.substring(0, colon)] = it }
}
}
val total = kb["MemTotal"] ?: return null
val available = kb["MemAvailable"] ?: return null
val swapTotal = kb["SwapTotal"] ?: 0L
val swapFree = kb["SwapFree"] ?: 0L
return Memory((total - available) * 1024, total * 1024, (swapTotal - swapFree) * 1024, swapTotal * 1024)
}
/** The KGSL total DroidDeck shares with the session (GpuMemComponent). */
fun vramBytes(): Long? = fresh(File(rootDir, VRAM_FILE), VRAM_FRESH_MS)?.let(::readLong)
/** BAT* under the session's power_supply (BatteryComponent): no watts while charging, full or unknown. */
fun battery(): Battery? {
val dirs = File(sessionDir, "sys/power_supply").listFiles { f -> f.name.contains("BAT") }?.sortedBy { it.name }
if (dirs.isNullOrEmpty()) return null
var chargeNow = 0f
var chargeFull = 0f
var watts = 0f
var charging = false
for (dir in dirs) {
val status = runCatching { File(dir, "status").readLines().firstOrNull() }.getOrNull()
if (status == "Charging" || status == "Unknown" || status == "Full") charging = true
val now = File(dir, "charge_now").takeIf { it.exists() } ?: File(dir, "energy_now")
val full = File(dir, "charge_full").takeIf { it.exists() } ?: File(dir, "energy_full")
if (now.exists()) {
chargeNow += (readFloat(now) ?: 0f) / 1_000_000f
chargeFull += (readFloat(full) ?: 0f) / 1_000_000f
} else {
chargeNow += (readFloat(File(dir, "capacity")) ?: 0f) / 100f
chargeFull = dirs.size.toFloat()
}
if (!charging) {
val power = File(dir, "power_now")
watts += if (power.exists()) kotlin.math.abs(readFloat(power) ?: 0f) / 1_000_000f
else kotlin.math.abs((readFloat(File(dir, "current_now")) ?: 0f) * (readFloat(File(dir, "voltage_now")) ?: 0f)) / 1e12f
}
}
val percent = if (chargeFull > 0f) Math.round(chargeNow / chargeFull * 100f) else null
return Battery(percent, if (charging) 0f else watts, charging)
}
private fun fresh(file: File, maxAgeMs: Long = FEED_FRESH_MS): File? =
file.takeIf { it.isFile && System.currentTimeMillis() - it.lastModified() < maxAgeMs }
companion object {
private const val KGSL = "/sys/class/kgsl/kgsl-3d0"
const val VRAM_FILE = "run/droiddeck-hud/gpu-mem"
private const val FEED_FRESH_MS = 10_000L
private const val VRAM_FRESH_MS = 15_000L
private val WHITESPACE = Regex("\\s+")
private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull()
private fun readFloat(file: File): Float? = runCatching { file.readLines().first().trim().toFloat() }.getOrNull()
/** std::stod on the first line: the leading number, "6 %" -> 6. */
fun leadingNumber(file: File): Double? = runCatching {
Regex("^\\s*-?\\d+(\\.\\d+)?").find(file.readLines().first())?.value?.trim()?.toDouble()
}.getOrNull()
}
}
@@ -0,0 +1,118 @@
package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.os.Build
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import com.droiddeck.launcher.R
@SuppressLint("ViewConstructor")
class DesktopKeyboardButton(context: Context, private val onTap: () -> Unit) : View(context) {
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = dp(1.5f) }
private val box = RectF()
private var idleFill = 0
private var idleStroke = 0
private var heldStroke = 0
private var pressed = false
init {
contentDescription = context.getString(R.string.second_show_keyboard)
isClickable = true
visibility = GONE
layoutParams = FrameLayout.LayoutParams(dp(SIZE).toInt(), dp(SIZE).toInt(), Gravity.BOTTOM or Gravity.START)
place(0, 0)
applyColors()
}
private fun dp(value: Float) = value * resources.displayMetrics.density
fun applyColors() {
val settings = ControllerPrefs.read(context)
val tint = settings.tint
val alpha = settings.opacity / 100f
fun light(a: Int, f: Float) = Color.argb(
(a * alpha).toInt(),
(Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
(Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
)
idleFill = Color.argb(190, (Color.red(tint) * 0.12f).toInt(), (Color.green(tint) * 0.12f).toInt(), (Color.blue(tint) * 0.12f).toInt())
idleStroke = light(150, 0.25f)
heldStroke = light(230, 0.7f)
invalidate()
}
private fun place(left: Int, bottom: Int) {
val params = layoutParams as FrameLayout.LayoutParams
val marginStart = left + (dp(CENTER_X) - dp(SIZE) / 2f).toInt()
val marginBottom = bottom + (dp(CENTER_Y) - dp(SIZE) / 2f).toInt()
if (params.leftMargin == marginStart && params.bottomMargin == marginBottom) return
params.leftMargin = marginStart
params.bottomMargin = marginBottom
layoutParams = params
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0)
return super.onApplyWindowInsets(insets)
}
override fun onDraw(canvas: Canvas) {
val x = width / 2f
val y = height / 2f
fill.color = idleFill
canvas.drawCircle(x, y, dp(18f), fill)
stroke.color = if (pressed) heldStroke else idleStroke
canvas.drawCircle(x, y, dp(18f), stroke)
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
for (row in 0..1) for (col in 0..2) {
val kx = x + dp(-6f + col * 5f)
val ky = y + dp(-3f + row * 4f)
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
}
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { pressed = true; invalidate() }
MotionEvent.ACTION_UP -> {
val inside = event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()
pressed = false
invalidate()
if (inside) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
performClick()
}
}
MotionEvent.ACTION_CANCEL -> { pressed = false; invalidate() }
}
return true
}
override fun performClick(): Boolean {
super.performClick()
onTap()
return true
}
private companion object {
const val SIZE = 52f
const val CENTER_X = 90f
const val CENTER_Y = 34f
}
}
@@ -8,52 +8,125 @@ import kotlin.math.abs
class DirectTouchHold(
private val slopPx: Float,
private val listener: PointerGestures.Listener,
private val cancelTouch: () -> Unit,
private val touch: Touch,
) {
interface Touch {
fun down(pointerId: Int, x: Float, y: Float)
fun move(pointerId: Int, x: Float, y: Float)
fun up(pointerId: Int, x: Float, y: Float)
fun cancel()
}
private val handler = Handler(Looper.getMainLooper())
private var x = 0f
private var y = 0f
private var consumed = false
private var pressed = false
private var downX = 0f
private var downY = 0f
private var downId = 0
private var delayMs = 0L
private var holdBehavior = TouchGestureConfig.MOUSE_BEHAVIOR_CLICK
private var forwarded = false
private var held = false
private var pressing = false
private var upX = 0f
private var upY = 0f
private var liftId = 0
private var liftPending = false
private val lift = Runnable {
liftPending = false
if (forwarded) {
touch.up(liftId, upX, upY)
forwarded = false
}
}
private val release = Runnable {
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
pressed = false
if (pressing) listener.onButton(PointerGestures.BTN_RIGHT, false, downX, downY)
pressing = false
}
private val hold = Runnable {
cancelTouch()
consumed = true
held = true
listener.onLongPress()
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
pressed = true
handler.postDelayed(release, 50L)
listener.onButton(PointerGestures.BTN_RIGHT, true, downX, downY)
pressing = true
if (holdBehavior != TouchGestureConfig.MOUSE_BEHAVIOR_HOLD) handler.postDelayed(release, 50L)
}
fun configure(config: TouchGestureConfig) {
cancel()
delayMs = if (config.longPressEnabled) config.longPressDelay.toLong().coerceAtLeast(1L) else 0L
delayMs = config.longPressDelay.toLong().coerceAtLeast(1L)
holdBehavior = config.directHoldBehavior
}
fun cancel() {
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
handler.removeCallbacks(lift)
liftPending = false
release.run()
consumed = false
if (forwarded) touch.cancel()
forwarded = false
held = false
}
fun onTouch(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
if (liftPending) {
handler.removeCallbacks(lift)
lift.run()
}
cancel()
downX = event.x
downY = event.y
downId = event.getPointerId(0)
if (holdBehavior == TouchGestureConfig.MOUSE_BEHAVIOR_NONE) forwardDown()
else handler.postDelayed(hold, delayMs)
return true
}
if (held) {
when (event.actionMasked) {
MotionEvent.ACTION_UP -> { release.run(); held = false }
MotionEvent.ACTION_CANCEL -> cancel()
}
return true
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
cancel()
x = event.x; y = event.y
if (delayMs > 0L) handler.postDelayed(hold, delayMs)
MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(hold)
if (!forwarded) forwardDown()
val index = event.actionIndex
touch.down(event.getPointerId(index), event.getX(index), event.getY(index))
}
MotionEvent.ACTION_MOVE -> {
if (!forwarded && (event.pointerCount > 1 ||
abs(event.x - downX) > slopPx || abs(event.y - downY) > slopPx)) {
handler.removeCallbacks(hold)
forwardDown()
}
if (forwarded) for (index in 0 until event.pointerCount)
touch.move(event.getPointerId(index), event.getX(index), event.getY(index))
}
MotionEvent.ACTION_POINTER_UP -> {
val index = event.actionIndex
touch.up(event.getPointerId(index), event.getX(index), event.getY(index))
}
MotionEvent.ACTION_UP -> {
handler.removeCallbacks(hold)
if (!forwarded) forwardDown()
upX = event.x
upY = event.y
liftId = event.getPointerId(event.actionIndex)
liftPending = true
handler.postDelayed(lift, LIFT_MS)
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
MotionEvent.ACTION_CANCEL -> cancel()
}
return consumed
return true
}
private fun forwardDown() {
touch.down(downId, downX, downY)
forwarded = true
}
private companion object {
const val LIFT_MS = 50L
}
}
@@ -0,0 +1,81 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.abs
import kotlin.math.roundToInt
object DualScreen {
const val MODE_AUTO = "auto"
const val MODE_ON = "on"
const val MODE_OFF = "off"
val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF)
private const val TYPE_INTERNAL = 1
private val knownModels = setOf("retroid pocket duo", "ayn thor")
private val touchedDisplays = mutableSetOf<Int>()
fun builtInDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? {
val primary = displayManager.getDisplay(primaryDisplayId)
val awake = primary?.state != Display.STATE_OFF
val panels = if (awake) touchPanels() else emptyList()
return internalDisplay(displayManager, primaryDisplayId) {
if (awake && it.state == Display.STATE_OFF) return@internalDisplay false
synchronized(touchedDisplays) {
if (awake && hasOwnTouchPanel(size(it), primary?.let(::size), panels)) touchedDisplays += it.displayId
it.displayId in touchedDisplays
}
}
}
private fun internalDisplay(displayManager: DisplayManager, primaryDisplayId: Int, accept: (Display) -> Boolean = { true }): Display? =
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0 &&
isBuiltIn(displayType(it), Build.MODEL) && accept(it)
}
internal fun hasOwnTouchPanel(display: Pair<Int, Int>, primary: Pair<Int, Int>?, panels: List<Pair<Int, Int>>): Boolean {
val matching = panels.count { covers(it, display) }
return if (primary != null && covers(primary, display)) matching >= 2 else matching >= 1
}
private fun covers(panel: Pair<Int, Int>, display: Pair<Int, Int>): Boolean =
(near(panel.first, display.first) && near(panel.second, display.second)) ||
(near(panel.first, display.second) && near(panel.second, display.first))
private fun near(a: Int, b: Int) = abs(a - b) <= 2
private fun size(display: Display): Pair<Int, Int> =
display.mode.let { it.physicalWidth to it.physicalHeight }
private fun touchPanels(): List<Pair<Int, Int>> =
InputDevice.getDeviceIds().toList().mapNotNull { id ->
val device = InputDevice.getDevice(id) ?: return@mapNotNull null
if (device.isVirtual || !device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN)) return@mapNotNull null
val x = device.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHSCREEN) ?: return@mapNotNull null
val y = device.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHSCREEN) ?: return@mapNotNull null
(x.max - x.min + 1f).roundToInt() to (y.max - y.min + 1f).roundToInt()
}
fun display(context: Context, displayManager: DisplayManager, primaryDisplayId: Int): Display? =
when (SessionPrefs.dualScreenMode(context)) {
MODE_OFF -> null
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
else -> builtInDisplay(displayManager, primaryDisplayId)
}
fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? =
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0
}
internal fun isBuiltIn(type: Int?, model: String): Boolean =
if (type != null) type == TYPE_INTERNAL else model.trim().lowercase() in knownModels
private fun displayType(display: Display): Int? =
runCatching { Display::class.java.getMethod("getType").invoke(display) as Int }.getOrNull()
}
@@ -14,6 +14,7 @@ public class FakeInputWriter {
public static final short EV_SYN = 0x00;
public static final short EV_KEY = 0x01;
public static final short EV_REL = 0x02;
public static final short EV_ABS = 0x03;
public static final short EV_MSC = 0x04;
@@ -52,7 +53,8 @@ public class FakeInputWriter {
private static final int EVENT_SIZE = 24;
static final int MAX_EVENTS_PER_UPDATE = 32;
private static final int BUFFER_SIZE = EVENT_SIZE * MAX_EVENTS_PER_UPDATE;
private static final int MAX_FAKE_INPUT_SLOTS = 4;
private static final int MAX_FAKE_INPUT_SLOTS = 5;
public static final int MOUSE_SLOT = 4;
private static final int RING_CAPACITY_EVENTS = 4096;
private static final int RING_HEADER_SIZE = 64;
@@ -88,6 +90,17 @@ public class FakeInputWriter {
public static final int PAD_TYPE_XBOX = 2;
public static final int PAD_TYPE_DS4 = 3;
public static final int PAD_TYPE_DUALSENSE = 4;
public static final int PAD_TYPE_MOUSE = 5;
public static final short REL_X = 0x00;
public static final short REL_Y = 0x01;
public static final short REL_HWHEEL = 0x06;
public static final short REL_WHEEL = 0x08;
public static final short REL_WHEEL_HI_RES = 0x0b;
public static final short REL_HWHEEL_HI_RES = 0x0c;
public static final short MOUSE_BTN_LEFT = 0x110;
public static final short MOUSE_BTN_RIGHT = 0x111;
public static final short MOUSE_BTN_MIDDLE = 0x112;
private static volatile int padType = -1;
private static volatile boolean tritonConnected = false;
private static final int TRITON_OFFSET = RING_SIZE + 64;
@@ -136,6 +149,8 @@ public class FakeInputWriter {
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
private int prevTriggerL, prevTriggerR;
private int prevHatX, prevHatY;
private final boolean[] prevMouseButtons = new boolean[3];
private float pendingMouseX, pendingMouseY;
private boolean hasChanges = false;
private boolean pendingFullResend = false;
private boolean forceResend = false;
@@ -201,7 +216,7 @@ public class FakeInputWriter {
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
}
private static void initializeRingHeader(ByteBuffer data) {
private static void initializeRingHeader(int slot, ByteBuffer data) {
data.order(ByteOrder.LITTLE_ENDIAN);
data.putInt(RING_MAGIC_OFFSET, RING_MAGIC);
data.putInt(RING_VERSION_OFFSET, RING_VERSION);
@@ -221,7 +236,7 @@ public class FakeInputWriter {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
int type = padType;
int type = slot == MOUSE_SLOT ? PAD_TYPE_MOUSE : padType;
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
data.putInt(LATENCY_OFFSET, latency);
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
@@ -312,7 +327,7 @@ public class FakeInputWriter {
raf.setLength(RING_FILE_SIZE);
channel = raf.getChannel();
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
initializeRingHeader(data);
initializeRingHeader(slot, data);
RingSlot ringSlot = new RingSlot();
ringSlot.data = data;
@@ -745,6 +760,14 @@ public class FakeInputWriter {
prevHatY = 0;
writeEvent(EV_ABS, ABS_HAT0Y, 0);
}
for (int i = 0; i < prevMouseButtons.length; i++) {
if (prevMouseButtons[i]) {
prevMouseButtons[i] = false;
writeEvent(EV_KEY, (short) (MOUSE_BTN_LEFT + i), 0);
}
}
pendingMouseX = 0;
pendingMouseY = 0;
if (hasChanges) {
writeEvent(EV_SYN, SYN_REPORT, 0);
@@ -874,4 +897,64 @@ public class FakeInputWriter {
public boolean isOpen() {
return isOpen;
}
public synchronized void writeMouseDelta(float dx, float dy) {
if (!isOpen && !open())
return;
pendingMouseX += dx;
pendingMouseY += dy;
int x = (int) Math.floor(pendingMouseX);
int y = (int) Math.floor(pendingMouseY);
if (x == 0 && y == 0)
return;
pendingMouseX -= x;
pendingMouseY -= y;
buffer.clear();
hasChanges = false;
if (x != 0) writeEvent(EV_REL, REL_X, x);
if (y != 0) writeEvent(EV_REL, REL_Y, y);
writeEvent(EV_SYN, SYN_REPORT, 0);
buffer.flip();
if (!flushBuffer())
Log.e(TAG, "Mouse delta write error: fake input mmap ring unavailable");
}
public synchronized void writeMouseButton(short code, boolean pressed) {
if (code < MOUSE_BTN_LEFT || code > MOUSE_BTN_MIDDLE)
return;
int idx = code - MOUSE_BTN_LEFT;
if (!isOpen && !open())
return;
if (prevMouseButtons[idx] == pressed)
return;
prevMouseButtons[idx] = pressed;
buffer.clear();
hasChanges = false;
writeEvent(EV_KEY, code, pressed ? 1 : 0);
writeEvent(EV_SYN, SYN_REPORT, 0);
buffer.flip();
if (!flushBuffer())
Log.e(TAG, "Mouse button write error: fake input mmap ring unavailable");
}
public synchronized void writeMouseWheel(int verticalClicks, int horizontalClicks, int value120) {
if (verticalClicks == 0 && horizontalClicks == 0)
return;
if (!isOpen && !open())
return;
buffer.clear();
hasChanges = false;
if (verticalClicks != 0) {
writeEvent(EV_REL, REL_WHEEL, verticalClicks);
writeEvent(EV_REL, REL_WHEEL_HI_RES, verticalClicks * value120);
}
if (horizontalClicks != 0) {
writeEvent(EV_REL, REL_HWHEEL, horizontalClicks);
writeEvent(EV_REL, REL_HWHEEL_HI_RES, horizontalClicks * value120);
}
writeEvent(EV_SYN, SYN_REPORT, 0);
buffer.flip();
if (!flushBuffer())
Log.e(TAG, "Mouse wheel write error: fake input mmap ring unavailable");
}
}
@@ -60,6 +60,7 @@ class HomeButtonService : AccessibilityService(), InputManager.InputDeviceListen
override fun onUnbind(intent: Intent?): Boolean {
HomeButtonShortcuts.release()
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
return super.onUnbind(intent)
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.input
import android.os.Build
import android.view.KeyEvent
class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCancel: () -> Unit = {}) {
@@ -8,6 +9,10 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa
fun onKeyEvent(event: KeyEvent, enabled: Boolean): Boolean {
if (!isHome(event.keyCode)) return false
if (isQuickMenuKey(event)) {
if (event.action == KeyEvent.ACTION_DOWN) cancel()
return false
}
if (!enabled) cancel()
val device = event.deviceId
return when (event.action) {
@@ -43,5 +48,10 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa
companion object {
// The Retroid Nova pad falls back to Generic.kl, which maps its Home scan code to MOVE_HOME.
fun isHome(keyCode: Int) = keyCode == KeyEvent.KEYCODE_HOME || keyCode == KeyEvent.KEYCODE_MOVE_HOME
private const val DUO_QUICK_MENU_SCAN_CODE = 194
fun isQuickMenuKey(event: KeyEvent) =
event.scanCode == DUO_QUICK_MENU_SCAN_CODE && "Retroid Pocket Duo".equals(Build.MODEL, ignoreCase = true)
}
}
@@ -23,6 +23,9 @@ class KeyboardHost(
private val map: KeyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
var shown = false
private set
var imeShowing = false
private set
var onImeChanged: ((Boolean) -> Unit)? = null
init {
isFocusable = false
@@ -42,6 +45,28 @@ class KeyboardHost(
return KeyConnection()
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val visible = insets.isVisible(WindowInsets.Type.ime())
if (visible != imeShowing) {
imeShowing = visible
onImeChanged?.invoke(visible)
}
}
return super.onApplyWindowInsets(insets)
}
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || keyCode != KeyEvent.KEYCODE_BACK || !shown) {
return super.onKeyPreIme(keyCode, event)
}
if (event.action == KeyEvent.ACTION_UP && !event.isCanceled) {
hide()
onImeChanged?.invoke(false)
}
return true
}
fun toggle() = if (shown && imeVisible()) hide() else show()
private fun imeVisible() = Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
@@ -672,6 +672,11 @@ class OnScreenControls(
private fun closestSnap(position: Float, targets: List<Float>): Float? =
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
/** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean =
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull {
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
@@ -0,0 +1,145 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
object PhysicalShortcuts {
internal const val KEY = "physicalShortcuts"
private const val TAG = "PhysicalShortcuts"
enum class Action(@StringRes val label: Int) {
STEAM(R.string.drawer_steam_menu),
QAM(R.string.drawer_open_qam),
DROIDDECK(R.string.shortcuts_dd_menu),
}
data class Button(
val device: String,
val name: String,
val keyCode: Int,
val scanCode: Int,
) {
fun matches(event: KeyEvent): Boolean =
event.device?.let { device == identity(it) } == true &&
keyCode == event.keyCode && scanCode == event.scanCode
fun sameInput(other: Button): Boolean =
device == other.device && keyCode == other.keyCode && scanCode == other.scanCode
val label: String
get() = KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_").removePrefix("BUTTON_")
}
data class Binding(val button: Button, val action: Action)
private fun identity(device: InputDevice): String =
"${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
fun isVolumeKey(event: KeyEvent): Boolean = when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> true
else -> false
}
fun isDrawerNavigationKey(event: KeyEvent): Boolean = when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> true
else -> false
}
fun button(event: KeyEvent): Button? {
val device = event.device ?: return null
if (device.isVirtual || isVolumeKey(event)) return null
when (event.keyCode) {
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
}
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
return Button(identity(device), device.name, event.keyCode, event.scanCode)
}
fun read(context: Context): List<Binding> {
val raw = ControllerPrefs.prefs(context).getString(KEY, null) ?: return emptyList()
return try {
val array = JSONArray(raw)
buildList {
for (i in 0 until array.length()) {
try {
val entry = array.getJSONObject(i)
val action = Action.entries.firstOrNull { it.name == entry.getString("action") }
val code = entry.getInt("keyCode")
val scan = entry.getInt("scanCode")
val device = entry.getString("device")
if (action == null || device.isBlank() || code < 0 || scan < 0) {
Log.w(TAG, "Ignoring invalid shortcut at index $i")
continue
}
val binding = Binding(Button(device, entry.getString("name"), code, scan), action)
if (none { it.button.sameInput(binding.button) }) add(binding)
} catch (e: JSONException) {
Log.w(TAG, "Ignoring malformed shortcut at index $i", e)
}
}
}
} catch (e: JSONException) {
Log.w(TAG, "Could not read physical shortcuts; reset shortcuts to restore defaults", e)
emptyList()
}
}
fun assign(context: Context, button: Button, action: Action) {
val bindings = read(context).filterNot {
it.button.sameInput(button) || (it.button.device == button.device && it.action == action)
} + Binding(button, action)
write(context, bindings)
}
fun remove(context: Context, binding: Binding) {
write(context, read(context).filterNot { it == binding })
}
private fun write(context: Context, bindings: List<Binding>) {
val array = JSONArray()
bindings.forEach {
array.put(JSONObject().put("device", it.button.device).put("name", it.button.name)
.put("keyCode", it.button.keyCode).put("scanCode", it.button.scanCode).put("action", it.action.name))
}
ControllerPrefs.prefs(context).edit().putString(KEY, array.toString()).apply()
}
}
class PhysicalShortcutRouter {
var bindings: List<PhysicalShortcuts.Binding> = emptyList()
private data class Press(val device: Int, val keyCode: Int, val scanCode: Int)
private val held = mutableSetOf<Press>()
fun onKey(event: KeyEvent, enabled: Boolean, steam: Boolean, perform: (PhysicalShortcuts.Action) -> Unit): Boolean {
val press = Press(event.deviceId, event.keyCode, event.scanCode)
if (event.action == KeyEvent.ACTION_UP) return held.remove(press)
if (event.action != KeyEvent.ACTION_DOWN) return false
if (press in held) return true
if (!enabled || bindings.isEmpty()) return false
val button = PhysicalShortcuts.button(event) ?: return false
val binding = bindings.firstOrNull { it.button.sameInput(button) } ?: return false
if (!steam && binding.action != PhysicalShortcuts.Action.DROIDDECK) return false
held.add(press)
if (event.repeatCount == 0) perform(binding.action)
return true
}
fun onDeviceRemoved(deviceId: Int) {
held.removeAll { it.device == deviceId }
}
fun clear() {
held.clear()
}
}
@@ -1,104 +1,22 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
import kotlin.math.roundToInt
class PointerGestures(
private val slopPx: Float,
private val listener: Listener,
) {
class PointerGestures {
interface Listener {
fun onMove(x: Float, y: Float)
fun onButton(button: Int, pressed: Boolean, x: Float, y: Float)
fun onWheel(steps: Int)
fun onLongPress()
}
private val handler = Handler(Looper.getMainLooper())
private var downX = 0f
private var downY = 0f
private var downAt = 0L
private var moved = false
private var held = false
private var dragging = false
private var scrolling = false
private var scrollAnchorY = 0f
private val longPress = Runnable {
held = true
listener.onLongPress()
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = event.x; downY = event.y; downAt = event.eventTime
moved = false; held = false; dragging = false; scrolling = false
listener.onMove(event.x, event.y)
handler.postDelayed(longPress, HOLD_MS)
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longPress)
if (dragging) { listener.onButton(BTN_LEFT, false, event.x, event.y); dragging = false }
scrolling = true
scrollAnchorY = event.getY(0)
return true
}
MotionEvent.ACTION_MOVE -> {
if (scrolling) {
val dy = event.getY(0) - scrollAnchorY
val steps = (dy / SCROLL_STEP_PX).toInt()
if (steps != 0) {
listener.onWheel(-steps)
scrollAnchorY += steps * SCROLL_STEP_PX
}
return true
}
if (!moved && (abs(event.x - downX) > slopPx || abs(event.y - downY) > slopPx)) {
moved = true
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(BTN_LEFT, true, downX, downY)
}
}
if (moved) listener.onMove(event.x, event.y)
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
handler.removeCallbacks(longPress)
val cancelled = event.actionMasked == MotionEvent.ACTION_CANCEL
when {
scrolling -> {}
dragging -> listener.onButton(BTN_LEFT, false, event.x, event.y)
held && !moved && !cancelled -> {
listener.onButton(BTN_RIGHT, true, downX, downY)
listener.onButton(BTN_RIGHT, false, downX, downY)
}
!moved && !cancelled && event.eventTime - downAt < TAP_MS -> {
listener.onButton(BTN_LEFT, true, downX, downY)
listener.onButton(BTN_LEFT, false, downX, downY)
}
}
scrolling = false; dragging = false; held = false
return true
}
MotionEvent.ACTION_POINTER_UP -> return true
}
return false
fun onScroll(dx: Float, dy: Float) {}
fun onScrollEnd() {}
fun onZoom(steps: Int) {}
}
companion object {
const val BTN_LEFT = 0x110
const val BTN_RIGHT = 0x111
const val BTN_MIDDLE = 0x112
private const val HOLD_MS = 450L
private const val TAP_MS = 350L
private const val SCROLL_STEP_PX = 40f
fun slop(context: android.content.Context) = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
}
@@ -609,6 +609,9 @@ class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit)
else -> entry.halfH
}
/** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean = editing || entryAt(x, y) != null
private fun entryAt(x: Float, y: Float): Entry? = entries.lastOrNull { entry ->
x >= entry.cx - entry.halfW && x <= entry.cx + entry.halfW && y >= entry.cy - entry.halfH && y <= entry.cy + entry.halfH
}
@@ -86,27 +86,14 @@ class ScrollTouchGestures(
lastY = startY
tracker = VelocityTracker.obtain().also { it.addMovement(event) }
listener.onMove(startX, startY)
handler.postDelayed(longPress, HOLD_MS)
handler.postDelayed(longPress, TouchTiming.HOLD_MS)
}
MotionEvent.ACTION_MOVE -> {
val index = event.findPointerIndex(pointerId).takeIf { it >= 0 } ?: return true
tracker?.addMovement(event)
val x = event.getX(index)
val y = event.getY(index)
if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) {
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true)
} else {
scrolling = true
vertical = abs(y - startY) >= abs(x - startX)
lastX = startX
lastY = startY
}
}
if (dragging) listener.onMove(x, y)
follow(event, x, y)
for (h in 0 until event.historySize)
sample(event.getHistoricalX(index, h), event.getHistoricalY(index, h), event.getHistoricalEventTime(h))
sample(event.getX(index), event.getY(index), event.eventTime)
}
MotionEvent.ACTION_POINTER_UP -> {
if (event.getPointerId(event.actionIndex) == pointerId) cancel()
@@ -115,7 +102,7 @@ class ScrollTouchGestures(
handler.removeCallbacks(longPress)
if (pointerId == -1 || event.getPointerId(0) != pointerId) return true
tracker?.addMovement(event)
follow(event, event.x, event.y)
follow(event.x, event.y, event.eventTime)
when {
scrolling -> tracker?.takeIf { event.eventTime - movedAt <= FLING_STILL_MS }?.let { t ->
t.computeCurrentVelocity(1000)
@@ -139,9 +126,26 @@ class ScrollTouchGestures(
return true
}
private fun follow(event: MotionEvent, x: Float, y: Float) {
private fun sample(x: Float, y: Float, t: Long) {
if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) {
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true)
} else {
scrolling = true
vertical = abs(y - startY) >= abs(x - startX)
lastX = startX
lastY = startY
}
}
if (dragging) listener.onMove(x, y)
follow(x, y, t)
}
private fun follow(x: Float, y: Float, t: Long) {
if (!scrolling || (x == lastX && y == lastY)) return
movedAt = event.eventTime
movedAt = t
if (vertical) listener.onScroll(0f, lastY - y) else listener.onScroll(lastX - x, 0f)
lastX = x
lastY = y
@@ -151,7 +155,7 @@ class ScrollTouchGestures(
releaseClick()
listener.onButton(button, true)
clicked = button
handler.postDelayed(clickUp, CLICK_MS)
handler.postDelayed(clickUp, TouchTiming.CLICK_MS)
}
private fun releaseClick() {
@@ -181,8 +185,6 @@ class ScrollTouchGestures(
return if (pixels < 0) -units else units
}
private const val HOLD_MS = 500L
private const val CLICK_MS = 50L
private const val FLING_TIME_S = 0.45f
private const val FLING_STOP_PX = 40f
private const val FLING_STILL_MS = 60L
@@ -9,7 +9,12 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
NONE("none", R.string.second_mode_none),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
TERMINAL("terminal", R.string.second_mode_terminal),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls);
companion object {
fun opening(remembered: String?, deckPad: Boolean): SecondScreenMode =
entries.firstOrNull { it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) } ?: if (deckPad) DECK_CONTROLS else KEYBOARD_TRACKPAD
}
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -16,13 +16,19 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
private val handler = Handler(Looper.getMainLooper())
private val hostFile = File(context.filesDir, "session/android-clipboard")
private var active = false
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh() }
private var published = false
private var shared: String? = null
private var echo: String? = null
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
private val guestListener = object : WaylandCompositor.ClipboardListener {
override fun onGuestClipboardText(text: String) {
handler.post {
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
shared = text
published = true
if (current?.toString() != text) {
echo = text
clipboard.setPrimaryClip(ClipData.newPlainText("DroidDeck", text))
}
}
@@ -46,17 +52,23 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
handler.removeCallbacksAndMessages(null)
}
fun refresh() {
fun refresh(changed: Boolean = false) {
if (!active || !hasFocus()) return
val clip = clipboard.primaryClip
if (clip == null) {
publish(null)
} else if (clip.itemCount > 0) {
publish(clip.getItemAt(0).text?.toString())
if (clip != null && clip.itemCount == 0) return
val text = clip?.getItemAt(0)?.text?.toString()
if (echo != null && text == echo) {
echo = null
return
}
echo = null
if (!changed && published && text == shared) return
publish(text)
}
private fun publish(text: String?) {
published = true
shared = text
WaylandCompositor.setClipboardText(text)
val pending = File(hostFile.parentFile, "android-clipboard.pending")
try {
@@ -244,6 +244,9 @@ class SteamTouchControls(
private fun hit(list: List<Element>, x: Float, y: Float): Element? =
list.filter { reach(it, x, y) <= 1.15f }.minByOrNull { reach(it, x, y) }
/** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean = editing || menuOpen || hit(live(), x, y) != null
private fun live(): List<Element> =
if (shakenAway) emptyList() else if (options.inputMode == SteamTouchConfig.INPUT_MOUSE) elements.filter { it.type == SteamTouchConfig.THUMB } else elements
@@ -15,6 +15,7 @@ data class TouchGestureConfig(
val longPressAction: String = ACTION_RIGHT_CLICK,
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val longPressDelay: Int = DEFAULT_DELAY_MS,
val directHoldBehavior: String = MOUSE_BEHAVIOR_CLICK,
val doubleTapEnabled: Boolean = true,
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
val twoFingerDragEnabled: Boolean = true,
@@ -50,6 +51,7 @@ data class TouchGestureConfig(
put(KEY_LONG_PRESS_ACTION, longPressAction)
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
put(KEY_LONG_PRESS_DELAY, longPressDelay)
put(KEY_DIRECT_HOLD_BEHAVIOR, directHoldBehavior)
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
@@ -85,6 +87,7 @@ data class TouchGestureConfig(
const val MOUSE_BEHAVIOR_HOLD = "hold"
const val MOUSE_BEHAVIOR_CLICK = "click"
const val MOUSE_BEHAVIOR_NONE = "none"
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
@@ -110,6 +113,7 @@ data class TouchGestureConfig(
private const val KEY_LONG_PRESS_ACTION = "longPressAction"
private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior"
private const val KEY_LONG_PRESS_DELAY = "longPressDelay"
private const val KEY_DIRECT_HOLD_BEHAVIOR = "directHoldBehavior"
private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled"
private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay"
private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled"
@@ -155,6 +159,9 @@ data class TouchGestureConfig(
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
directHoldBehavior = normalizeDirectHoldBehavior(
obj.optString(KEY_DIRECT_HOLD_BEHAVIOR, MOUSE_BEHAVIOR_CLICK)
),
doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true),
doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS),
twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true),
@@ -190,6 +197,10 @@ data class TouchGestureConfig(
}
}
private fun normalizeDirectHoldBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK || value == MOUSE_BEHAVIOR_NONE) value else MOUSE_BEHAVIOR_HOLD
}
private fun normalizeMouseBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.input
import com.droiddeck.launcher.session.SessionPrefs
object TouchRouter {
enum class Route { BLOCKED, GESTURES, SCROLL, OFF, TOUCHPAD, DIRECT }
data class Snapshot(
val uiBlocking: Boolean,
val mode: String,
val scrollLayer: Boolean,
val steamSession: Boolean,
val steamDesktopUi: Boolean,
)
fun decide(s: Snapshot): Route {
if (s.uiBlocking) return Route.BLOCKED
if (s.mode == SessionPrefs.TOUCH_GESTURES) return Route.GESTURES
if (s.scrollLayer) return Route.SCROLL
if (s.mode == SessionPrefs.TOUCH_OFF) return Route.OFF
return if (touchpad(s)) Route.TOUCHPAD else Route.DIRECT
}
fun steamKeyboardZone(s: Snapshot): Boolean = s.steamSession && !s.steamDesktopUi
private fun touchpad(s: Snapshot): Boolean = when (s.mode) {
SessionPrefs.TOUCH_PAD -> true
SessionPrefs.TOUCH_DIRECT -> false
else -> !s.steamSession || s.steamDesktopUi
}
}
@@ -0,0 +1,9 @@
package com.droiddeck.launcher.input
object TouchTiming {
const val HOLD_MS = 500L
const val TAP_MS = 300L
const val CLICK_MS = 30L
const val DOUBLE_TAP_MS = 300L
const val SCROLL_STEP_PX = 36f
}
@@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import kotlin.math.abs
import kotlin.math.hypot
class TouchpadGestures(
private val slopPx: Float,
@@ -15,6 +16,7 @@ class TouchpadGestures(
private set
var bounds: android.graphics.RectF = android.graphics.RectF(0f, 0f, 1f, 1f)
var sensitivity = 1.15f
var desktop = false
private val handler = Handler(Looper.getMainLooper())
private var lastX = 0f
@@ -27,7 +29,11 @@ class TouchpadGestures(
private var secondDownAt = 0L
private var secondWasTap = false
private var twoFingers = false
private var scrollAnchorX = 0f
private var scrollAnchorY = 0f
private var fingers = 0
private var pinching = false
private var pinchSpan = 0f
private var scrolled = false
private var anchorId = -1
private var anchorStartX = 0f
@@ -52,7 +58,7 @@ class TouchpadGestures(
releaseClick()
listener.onButton(button, true, x, y)
clicked = button
handler.postDelayed(clickUp, CLICK_MS)
handler.postDelayed(clickUp, TouchTiming.CLICK_MS)
}
private fun releaseClick() {
@@ -79,6 +85,20 @@ class TouchpadGestures(
y = py.coerceIn(bounds.top, bounds.bottom)
}
private fun trackMove(px: Float, py: Float) {
val dx = px - lastX
val dy = py - lastY
lastX = px; lastY = py
travelled += abs(dx) + abs(dy)
if (travelled > slopPx) {
handler.removeCallbacks(longPress)
val speed = abs(dx) + abs(dy)
val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f))
place(x + dx * gain, y + dy * gain)
listener.onMove(x, y)
}
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
@@ -86,18 +106,20 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
fingers = 1; pinching = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
if (event.eventTime - lastTapAt < TouchTiming.DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
} else {
dragging = false
handler.postDelayed(longPress, HOLD_MS)
handler.postDelayed(longPress, TouchTiming.HOLD_MS)
}
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longPress)
fingers = maxOf(fingers, event.pointerCount)
val i = event.actionIndex
if (moverId == -1 && event.getPointerId(i) != anchorId) {
moverId = event.getPointerId(i)
@@ -111,7 +133,9 @@ class TouchpadGestures(
twoFingers = true
secondDownAt = event.eventTime
secondWasTap = true
scrollAnchorX = (event.getX(0) + event.getX(1)) / 2f
scrollAnchorY = (event.getY(0) + event.getY(1)) / 2f
pinchSpan = span(event)
return true
}
MotionEvent.ACTION_MOVE -> {
@@ -146,29 +170,49 @@ class TouchpadGestures(
} else if (holdDrag) {
return true
}
if (twoFingers && event.pointerCount >= 2) {
val mid = (event.getY(0) + event.getY(1)) / 2f
val dy = mid - scrollAnchorY
val steps = (dy / SCROLL_STEP_PX).toInt()
if (steps != 0) {
listener.onWheel(-steps)
scrollAnchorY += steps * SCROLL_STEP_PX
if (twoFingers && event.pointerCount >= 2 && desktop) {
if (fingers >= 3) return true
val span = span(event)
if (!scrolled && !pinching && abs(span - pinchSpan) > slopPx * PINCH_SLOPS) {
pinching = true
secondWasTap = false
pinchSpan = span
}
if (pinching) {
val steps = ((span - pinchSpan) / PINCH_STEP_PX).toInt()
if (steps != 0) {
listener.onZoom(-steps)
pinchSpan += steps * PINCH_STEP_PX
}
return true
}
val midX = (event.getX(0) + event.getX(1)) / 2f
val midY = (event.getY(0) + event.getY(1)) / 2f
val dx = scrollAnchorX - midX
val dy = scrollAnchorY - midY
if (scrolled || abs(dx) + abs(dy) > slopPx) {
listener.onScroll(dx, dy)
scrollAnchorX = midX; scrollAnchorY = midY
scrolled = true
secondWasTap = false
}
return true
}
val dx = event.x - lastX
val dy = event.y - lastY
lastX = event.x; lastY = event.y
travelled += abs(dx) + abs(dy)
if (travelled > slopPx) {
handler.removeCallbacks(longPress)
val speed = (abs(dx) + abs(dy))
val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f))
place(x + dx * gain, y + dy * gain)
listener.onMove(x, y)
if (twoFingers && event.pointerCount >= 2) {
val mid = (event.getY(0) + event.getY(1)) / 2f
val dy = mid - scrollAnchorY
val steps = (dy / TouchTiming.SCROLL_STEP_PX).toInt()
if (steps != 0) {
listener.onWheel(-steps)
scrollAnchorY += steps * TouchTiming.SCROLL_STEP_PX
scrolled = true
secondWasTap = false
}
return true
}
for (h in 0 until event.historySize)
trackMove(event.getHistoricalX(0, h), event.getHistoricalY(0, h))
trackMove(event.x, event.y)
return true
}
MotionEvent.ACTION_POINTER_UP -> {
@@ -194,7 +238,12 @@ class TouchpadGestures(
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
if (desktop && scrolled && event.pointerCount == 2) listener.onScrollEnd()
if (fingers >= 3) {
if (event.pointerCount == 2 && secondWasTap && !scrolled && event.eventTime - downAt < TouchTiming.TAP_MS * 2)
click(PointerGestures.BTN_MIDDLE)
if (event.pointerCount == 2) secondWasTap = false
} else if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TouchTiming.TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
secondWasTap = false
}
@@ -206,29 +255,30 @@ class TouchpadGestures(
when {
dragging -> listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
twoFingers -> {
if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TAP_MS * 2) {
if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TouchTiming.TAP_MS * 2) {
click(PointerGestures.BTN_RIGHT)
}
}
!cancelled && travelled <= slopPx && event.eventTime - downAt < TAP_MS -> {
!cancelled && travelled <= slopPx && event.eventTime - downAt < TouchTiming.TAP_MS -> {
click(PointerGestures.BTN_LEFT)
lastTapAt = event.eventTime
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
pinching = false; fingers = 0
return true
}
}
return false
}
private fun span(event: MotionEvent): Float =
if (event.pointerCount < 2) 0f else hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
companion object {
private const val HOLD_MS = 500L
private const val TAP_MS = 300L
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val PINCH_SLOPS = 3f
private const val PINCH_STEP_PX = 60f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
@@ -36,7 +36,7 @@ public final class TouchscreenGestures {
private final Output output;
private final float doubleTapSlop;
private TouchGestureConfig gestureConfig = new TouchGestureConfig();
private static final int CLICK_DELAYED_TIME = 50;
private static final int CLICK_DELAYED_TIME = 30;
private Runnable delayedPress, pendingHoldClickRelease;
private Runnable pendingTapPress;
private final ArrayDeque<TapClick> pendingTapClicks = new ArrayDeque<>();
@@ -0,0 +1,205 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import kotlin.math.abs
import kotlin.math.hypot
class TwoFingerGestures(private val slopPx: Float, private val listener: Listener) {
interface Listener {
fun cancelTouch()
fun replay(event: MotionEvent)
fun rightClick(x: Float, y: Float)
fun scrollStart(x: Float, y: Float)
fun scroll(dx: Float, dy: Float)
fun scrollEnd()
}
private enum class State { IDLE, PENDING, CANDIDATE, TAPPED, HELD, SCROLLING, PASSING }
private val handler = Handler(Looper.getMainLooper())
private var state = State.IDLE
private var x = 0f
private var y = 0f
private var twoAt = 0L
private var startSpan = 0f
private var lastX = 0f
private var lastY = 0f
private val starts = HashMap<Int, Pair<Float, Float>>()
private val held = ArrayList<MotionEvent>()
private val hold = Runnable {
if (state != State.CANDIDATE) return@Runnable
state = State.HELD
drop()
listener.cancelTouch()
listener.rightClick(x, y)
}
private val release = Runnable {
if (state == State.PENDING) pass(State.IDLE)
}
fun cancel() {
if (state == State.SCROLLING) listener.scrollEnd()
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
drop()
state = State.IDLE
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
cancel()
starts.clear()
x = event.x
y = event.y
note(event, event.actionIndex)
state = State.PENDING
keep(event)
handler.postDelayed(release, LAND_MS)
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
note(event, event.actionIndex)
when (state) {
State.PENDING -> {
handler.removeCallbacks(release)
keep(event)
if (event.pointerCount != 2) {
pass(State.PASSING)
return true
}
state = State.CANDIDATE
twoAt = event.eventTime
startSpan = span(event)
lastX = midX(event)
lastY = midY(event)
handler.postDelayed(hold, HOLD_MS)
return true
}
State.CANDIDATE -> {
keep(event)
pass(State.PASSING)
return true
}
State.SCROLLING, State.HELD, State.TAPPED -> return true
else -> Unit
}
}
MotionEvent.ACTION_MOVE -> when (state) {
State.PENDING -> {
keep(event)
if (moved(event)) pass(State.IDLE)
return true
}
State.CANDIDATE -> {
if (event.pointerCount == 2 && moved(event)) {
handler.removeCallbacks(hold)
if (abs(span(event) - startSpan) > slopPx * 2) {
keep(event)
pass(State.PASSING)
return true
}
state = State.SCROLLING
drop()
listener.cancelTouch()
lastX = midX(event)
lastY = midY(event)
listener.scrollStart(lastX, lastY)
return true
}
keep(event)
return true
}
State.SCROLLING -> {
if (event.pointerCount >= 2) {
val mx = midX(event)
val my = midY(event)
listener.scroll(lastX - mx, lastY - my)
lastX = mx
lastY = my
}
return true
}
State.TAPPED, State.HELD -> return true
else -> Unit
}
MotionEvent.ACTION_POINTER_UP -> when (state) {
State.CANDIDATE -> {
handler.removeCallbacks(hold)
keep(event)
if (event.eventTime - twoAt < TAP_MS) {
state = State.TAPPED
drop()
return true
}
pass(State.PASSING)
return true
}
State.SCROLLING, State.HELD, State.TAPPED -> return true
else -> Unit
}
MotionEvent.ACTION_UP -> {
val was = state
if (was == State.PENDING) {
keep(event)
pass(State.IDLE)
return true
}
cancel()
when (was) {
State.TAPPED -> {
listener.cancelTouch()
listener.rightClick(x, y)
return true
}
State.SCROLLING, State.HELD -> return true
else -> Unit
}
}
MotionEvent.ACTION_CANCEL -> cancel()
}
return false
}
private fun keep(event: MotionEvent) {
held += MotionEvent.obtain(event)
}
private fun drop() {
held.forEach { it.recycle() }
held.clear()
}
private fun pass(next: State) {
handler.removeCallbacks(release)
handler.removeCallbacks(hold)
state = next
val events = held.toList()
held.clear()
events.forEach {
listener.replay(it)
it.recycle()
}
}
private fun note(event: MotionEvent, index: Int) {
starts[event.getPointerId(index)] = event.getX(index) to event.getY(index)
}
private fun moved(event: MotionEvent): Boolean = (0 until event.pointerCount).any { i ->
val start = starts[event.getPointerId(i)] ?: return@any false
hypot(event.getX(i) - start.first, event.getY(i) - start.second) > slopPx
}
private fun span(event: MotionEvent) = hypot(event.getX(1) - event.getX(0), event.getY(1) - event.getY(0))
private fun midX(event: MotionEvent) = (event.getX(0) + event.getX(1)) / 2f
private fun midY(event: MotionEvent) = (event.getY(0) + event.getY(1)) / 2f
companion object {
const val LAND_MS = 250L
const val TAP_MS = 350L
const val HOLD_MS = 500L
}
}
@@ -180,7 +180,8 @@ object BwrapSpawner {
val env = LinkedHashMap<String, String>()
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
val libc = listOf("/usr/lib/aarch64-linux-gnu/libc.so.6", "/lib/aarch64-linux-gnu/libc.so.6").map { hostPath(binds, root.path, it) }
if (preloadFits(rootfs, libc.firstOrNull { File(it).isFile } ?: libc.first())) preload(session, rootfs, env).forEach { addBind(it) }
zypakShims(binds, root, dir).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
@@ -188,16 +189,20 @@ object BwrapSpawner {
private const val GPU_DIR = "/run/droiddeck-gpu"
private val LIB_DIRS = listOf("usr/lib/aarch64-linux-gnu", "usr/lib")
private val GPU_LIBS = listOf("libdisplay-info.so.3", "libdisplay-info.so.2", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")
private fun runtimeLib(rootfs: String, name: String): File? =
LIB_DIRS.map { File(rootfs, "$it/$name") }.firstOrNull { it.exists() }
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
val lib = File(rootfs, "usr/lib")
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
val driver = runtimeLib(rootfs, "libvulkan_freedreno.so")
if (driver == null || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
if (env["BL_DESKTOP"] == "1" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
val binds = arrayListOf(Bind(driver.canonicalPath, "$GPU_DIR/libvulkan_freedreno.so"))
for (name in GPU_LIBS) {
val f = runtimeLib(rootfs, name) ?: continue
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
}
val icd = File(root.parentFile, "freedreno_icd.json")
@@ -227,6 +232,27 @@ object BwrapSpawner {
return binds
}
private val GLIBC_RELEASE = Regex("release version 2\\.(\\d+)")
private val GLIBC_NEED = Regex("GLIBC_2\\.(\\d+)")
private val glibcMinors = java.util.concurrent.ConcurrentHashMap<String, Int>()
private fun glibcMinor(file: File, pattern: Regex, pick: (Sequence<Int>) -> Int?): Int? {
val key = "${file.path}:${file.length()}:${file.lastModified()}:${pattern.pattern}"
glibcMinors[key]?.let { return it }
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: return null
return pick(pattern.findAll(text).map { it.groupValues[1].toInt() })?.also { glibcMinors[key] = it }
}
internal fun preloadFits(rootfs: String, sandboxLibc: String): Boolean {
val libc = File(sandboxLibc)
if (!libc.isFile) return true
val have = glibcMinor(libc, GLIBC_RELEASE) { it.firstOrNull() } ?: return true
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
?.map { it.trim() }?.filter { it.startsWith("/") } ?: return true
val need = libs.mapNotNull { File(rootfs + it).takeIf { f -> f.isFile }?.let { f -> glibcMinor(f, GLIBC_NEED) { m -> m.maxOrNull() } } }.maxOrNull() ?: return true
return have >= need
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
private const val NET_LINK = "etc/droiddeck-net"
@@ -321,7 +347,15 @@ object BwrapSpawner {
catch (e: IOException) { }
if (process.isAlive) {
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
process.destroy()
if (pid > 0) {
runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) }
} else {
process.destroy()
}
try {
if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly()
} catch (_: InterruptedException) {
}
}
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
val buffer = ByteArray(16384)
@@ -115,19 +115,27 @@ object DesktopCatalog {
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
const val DESKTOP_ID = "desktop-kde"
const val EMULATORS_ID = "emulators"
private const val EMULATORS_SCRIPT = "/usr/local/bin/droiddeck-emulators"
fun desktopEntry(context: Context) = Entry(
DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, BuildConfig.DESKTOP_KDE_TAG, "tar",
BuildConfig.DESKTOP_KDE_URL, BuildConfig.DESKTOP_KDE_SHA256, BuildConfig.DESKTOP_KDE_SIZE, "",
"", "",
)
fun desktopEntry(context: Context): Entry {
val pack = DesktopPack.info(LinuxRuntime.rootDir(context))
return Entry(
DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, pack?.tag ?: BuildConfig.DESKTOP_KDE_TAG, "tar",
pack?.url ?: BuildConfig.DESKTOP_KDE_URL, pack?.sha256 ?: BuildConfig.DESKTOP_KDE_SHA256,
pack?.size ?: BuildConfig.DESKTOP_KDE_SIZE, "", "", "",
)
}
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
fun desktopFromPack(root: File): Boolean = DesktopPack.info(root) != null
fun desktopInstalled(root: File): Boolean =
!File(root, DesktopRemoval.PENDING).exists() &&
File(root, "usr/bin/kwin_wayland").isFile &&
FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() == BuildConfig.DESKTOP_KDE_TAG
FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() ==
(DesktopPack.info(root)?.tag ?: BuildConfig.DESKTOP_KDE_TAG)
fun desktopPresent(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
@@ -169,6 +177,7 @@ object DesktopCatalog {
listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.id == EMULATORS_ID && desktopFromPack(root)) return installEmulators(context, entry, cancelled, listener)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
@@ -217,11 +226,12 @@ object DesktopCatalog {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (entry.id == DESKTOP_ID && desktopFromPack(root)) DesktopPack.prepareInstall(root)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, report)
if (desktopFromPack(root)) DesktopPack.finishInstall(root) else cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
@@ -239,6 +249,47 @@ object DesktopCatalog {
}
}
private fun installEmulators(context: Context, entry: Entry, cancelled: Downloader.Cancellation?,
listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val stop = java.util.concurrent.atomic.AtomicBoolean(false)
val finished = java.util.concurrent.atomic.AtomicBoolean(false)
cancelled?.let { token ->
Thread({
try {
while (!finished.get() && !stop.get()) {
if (token.getAsBoolean()) stop.set(true) else Thread.sleep(200)
}
} catch (_: InterruptedException) {
}
}, "emulators-cancel").apply { isDaemon = true; start() }
}
try {
var failure: String? = null
val status = GuestCommand.run(context, listOf("/bin/bash", EMULATORS_SCRIPT, "install"), logName = "emulators",
cancelled = stop) { line ->
when {
line.startsWith("== STEP ") -> {
val stage = context.getString(R.string.deskpkg_installing, line.removePrefix("== STEP "))
RuntimeInstallService.packageProgress(stage, -1)
listener?.onProgress(stage, -1)
}
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
}
}
if (stop.get()) return context.getString(R.string.user_apps_download_failed)
if (failure != null || status != 0) return context.getString(R.string.deskpkg_install_failed)
check(FileUtils.writeString(marker(context, entry.id), entry.version)) { "Could not finish package installation" }
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
finished.set(true)
release()
}
}
private fun unzipAppImage(zip: File, target: File): Boolean {
try {
java.util.zip.ZipInputStream(java.io.BufferedInputStream(java.io.FileInputStream(zip))).use { zin ->
@@ -73,10 +73,42 @@ internal object DesktopFileRemoval {
}
}
private val RECORD = Regex("[A-Za-z0-9@._+:-]+")
fun desktopRecords(root: File): Set<String> {
val list = safeFile(root, "usr/share/droiddeck/pacman/desktop-packages.txt") ?: return emptySet()
if (!Files.isRegularFile(list.toPath(), NOFOLLOW_LINKS)) return emptySet()
return list.readLines().mapNotNull { line ->
line.split(' ').takeIf { it.size == 2 }?.let { "${it[0]}-${it[1]}" }?.takeIf { RECORD.matches(it) && it != "." && it != ".." }
}.toSet()
}
fun removeRecords(root: File, records: Set<String>) {
val lib = File(root, "var/lib/pacman")
if (Files.isSymbolicLink(File(root, "var").toPath()) || Files.isSymbolicLink(lib.toPath())) return
val local = File(lib, "local")
if (!local.isDirectory || Files.isSymbolicLink(local.toPath())) return
for (record in records) {
if (!RECORD.matches(record) || record == "." || record == "..") continue
val dir = File(local, record)
if (Files.isSymbolicLink(dir.toPath()) || !dir.isDirectory) continue
for (file in dir.listFiles().orEmpty()) {
if (Files.isSymbolicLink(file.toPath()) || file.isFile) Files.delete(file.toPath())
}
if (!dir.delete()) throw IOException("Cannot remove $dir")
}
for (name in listOf("sync/droiddeck.db", "droiddeck-desktop")) {
val file = File(lib, name)
if (Files.isSymbolicLink(file.toPath()) || file.isFile) Files.delete(file.toPath())
}
}
fun runtimeFiles(root: File): Set<String> = buildSet {
val database = File(root, "var/lib/pacman/local")
if (!database.isDirectory || Files.isSymbolicLink(database.toPath())) throw IOException("Runtime package database missing")
val desktop = desktopRecords(root)
for (pkg in database.listFiles().orEmpty()) {
if (pkg.name in desktop) continue
val files = File(pkg, "files")
if (!pkg.isDirectory || Files.isSymbolicLink(pkg.toPath()) || !files.isFile || Files.isSymbolicLink(files.toPath())) continue
var inFiles = false
@@ -0,0 +1,178 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.zip.GZIPInputStream
import org.json.JSONObject
internal object DesktopPack {
const val INFO = "usr/share/droiddeck/desktop-pack.json"
const val META = "usr/share/droiddeck/desktop"
private const val MANIFEST = "$META/files.tsv.gz"
private const val ORIGINALS = "$META/originals"
private const val STATUS = "var/lib/dpkg/status"
private const val STATUS_BEFORE = "var/lib/dpkg/status.droiddeck"
private const val PACK_STATUS = "$META/dpkg-status"
data class Info(val tag: String, val url: String, val sha256: String, val size: Long)
data class Entry(val kind: Char, val type: Char, val size: Long, val value: String, val path: String) {
companion object {
fun parse(line: String): Entry? {
val parts = line.split('\t', limit = 5)
if (parts.size != 5 || parts[0].length != 1 || parts[1].length != 1) return null
return Entry(parts[0][0], parts[1][0], parts[2].toLongOrNull() ?: return null, parts[3], parts[4])
}
}
}
data class Result(val removed: Int, val restored: Int, val kept: Int)
fun info(root: File): Info? = runCatching {
val json = JSONObject(FileUtils.readString(File(root, INFO)) ?: return null)
Info(json.getString("tag"), json.getString("url"), json.getString("sha256"), json.getLong("size"))
}.getOrNull()
fun installed(root: File): Boolean = File(root, MANIFEST).isFile
fun entries(root: File): List<Entry> =
GZIPInputStream(File(root, MANIFEST).inputStream()).bufferedReader().use { reader ->
reader.lineSequence().mapNotNull(Entry::parse).toList()
}
fun installedSize(root: File): Long =
if (installed(root)) entries(root).filter { it.kind == 'N' && it.type == 'F' }.sumOf { it.size } else 0L
fun prepareInstall(root: File) {
val live = File(root, STATUS)
val before = File(root, STATUS_BEFORE)
if (!before.exists() && live.isFile) Files.copy(live.toPath(), before.toPath(), StandardCopyOption.COPY_ATTRIBUTES)
}
fun finishInstall(root: File) {
for (entry in entries(root).filter { it.kind == 'R' }.sortedByDescending { it.path.length }) {
val file = inside(root, entry.path) ?: continue
if (entry.type == 'D') file.delete() else Files.deleteIfExists(file.toPath())
}
val live = File(root, STATUS)
val packStatus = File(root, PACK_STATUS)
if (live.isFile && !packStatus.exists()) Files.copy(live.toPath(), packStatus.toPath())
val before = File(root, STATUS_BEFORE)
val runtime = File(File(root, ORIGINALS), STATUS)
if (before.isFile && runtime.isFile && live.isFile) {
writeStatus(live, mergeStatus(runtime.readText(), packStatus.readText(), before.readText()))
}
Files.deleteIfExists(before.toPath())
}
internal fun mergeStatus(base: String, target: String, mine: String): String {
val from = stanzas(base)
val into = LinkedHashMap(stanzas(target))
val kept = stanzas(mine)
for ((key, stanza) in kept) {
if (from[key] != stanza && into[key] == from[key]) into[key] = stanza
}
for (key in from.keys) {
if (key !in kept && into[key] == from[key]) into.remove(key)
}
return into.values.joinToString("") { it + "\n\n" }
}
private fun stanzas(text: String): Map<String, String> {
val out = LinkedHashMap<String, String>()
for (stanza in text.split(Regex("\n[ \t]*\n")).map { it.trim('\n') }.filter { it.isNotBlank() }) {
val fields = stanza.lines()
fun field(name: String) = fields.firstOrNull { it.startsWith("$name:") }?.substringAfter(':')?.trim().orEmpty()
out["${field("Package")}:${field("Architecture")}"] = stanza
}
return out
}
private fun writeStatus(file: File, text: String) {
val part = LinuxRuntimeInstaller.partFile(file).toPath()
Files.deleteIfExists(part)
Files.copy(file.toPath(), part, StandardCopyOption.COPY_ATTRIBUTES)
Files.write(part, text.toByteArray(), java.nio.file.StandardOpenOption.TRUNCATE_EXISTING)
Files.move(part, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
}
fun remove(root: File, progress: (Int) -> Unit = {}): Result {
val entries = entries(root)
var removed = 0
var restored = 0
var kept = 0
val originals = File(root, ORIGINALS)
val live = File(root, STATUS)
val userStatus = if (live.isFile) live.readText() else null
for (entry in entries.filter { it.kind == 'C' || it.kind == 'R' }) {
val file = inside(root, entry.path) ?: continue
if (entry.type == 'D') {
file.mkdirs()
continue
}
if (entry.kind == 'C' && !entry.path.startsWith("var/lib/dpkg/") && !matchesOrMissing(file, entry)) {
kept++
continue
}
val source = inside(originals, entry.path) ?: continue
val sourcePath = source.toPath()
if (!Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) continue
file.parentFile?.mkdirs()
val part = LinuxRuntimeInstaller.partFile(file).toPath()
Files.deleteIfExists(part)
if (Files.isSymbolicLink(sourcePath)) Files.createSymbolicLink(part, Files.readSymbolicLink(sourcePath))
else Files.copy(sourcePath, part, StandardCopyOption.COPY_ATTRIBUTES)
if (Files.isDirectory(file.toPath(), LinkOption.NOFOLLOW_LINKS)) FileUtils.delete(file)
Files.move(part, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
restored++
}
val added = entries.filter { it.kind == 'N' }
val directories = ArrayList<File>()
added.forEachIndexed { index, entry ->
val file = inside(root, entry.path)
when {
file == null -> kept++
entry.type == 'D' -> directories += file
matches(file, entry) -> { Files.deleteIfExists(file.toPath()); removed++ }
Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS) -> kept++
}
if (index % 2000 == 0) progress(index * 100 / added.size.coerceAtLeast(1))
}
for (dir in directories.sortedByDescending { it.path.length }) {
if (Files.isDirectory(dir.toPath(), LinkOption.NOFOLLOW_LINKS) && dir.list()?.isEmpty() == true) dir.delete()
}
val packStatus = File(root, PACK_STATUS)
if (userStatus != null && packStatus.isFile && live.isFile) {
writeStatus(live, mergeStatus(packStatus.readText(), live.readText(), userStatus))
}
FileUtils.delete(File(root, META))
return Result(removed, restored, kept)
}
private fun matchesOrMissing(file: File, entry: Entry) =
!Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS) || matches(file, entry)
private fun matches(file: File, entry: Entry): Boolean {
val path = file.toPath()
return when (entry.type) {
'L' -> Files.isSymbolicLink(path) && Files.readSymbolicLink(path).toString() == entry.value
'F' -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && file.length() == entry.size &&
Hashes.sha256(file).equals(entry.value, ignoreCase = true)
else -> false
}
}
internal fun inside(root: File, path: String): File? {
if (path.isEmpty() || path.startsWith("/")) return null
val normal = Paths.get(path).normalize()
if (normal.toString().isEmpty() || normal.startsWith("..")) return null
val parent = normal.parent
val dir = if (parent == null) root else com.droiddeck.launcher.core.ArchivePaths.inside(root, parent.toString()) ?: return null
return File(dir, normal.fileName.toString())
}
}
@@ -18,8 +18,11 @@ internal object DesktopRemoval {
private fun protected(context: Context, root: File) =
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
fun installedSize(context: Context): Long =
lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
fun installedSize(context: Context): Long {
val root = LinuxRuntime.rootDir(context)
if (DesktopPack.info(root) != null) return DesktopPack.installedSize(root)
return lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
}
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
val root = LinuxRuntime.rootDir(context)
@@ -40,8 +43,12 @@ internal object DesktopRemoval {
Log.i("DesktopRemoval", "LXQt: $result")
}
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? {
val root = LinuxRuntime.rootDir(context)
return if (DesktopPack.info(root) != null) removePack(context, root, listener) else removeLegacy(context, root, listener)
}
private fun removeLegacy(context: Context, root: File, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
val pending = File(root, PENDING)
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
val installing = DesktopCatalog.interrupted(root).firstOrNull { it.id == DesktopCatalog.DESKTOP_ID }
@@ -50,12 +57,14 @@ internal object DesktopRemoval {
check(version in versions && (installing == null || installing.version in versions)) { context.getString(R.string.desktop_removal_unknown) }
val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse)
val keep = protected(context, root)
val records = DesktopFileRemoval.desktopRecords(root)
cleanLegacy(context, listener)
FileUtils.writeString(pending, version!!)
check(FileUtils.readString(pending)?.trim() == version) { "Could not reserve desktop removal" }
if (marker.exists() && !marker.delete()) error("Could not clear desktop marker")
val removing = context.getString(R.string.desktop_removing)
val result = DesktopFileRemoval.remove(root, entries, keep) { listener.onProgress(removing, it) }
DesktopFileRemoval.removeRecords(root, records)
for ((asset, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
val target = DesktopFileRemoval.safeFile(root, path) ?: continue
if (!isRegularFile(target)) continue
@@ -74,5 +83,29 @@ internal object DesktopRemoval {
e.message ?: context.getString(R.string.deskpkg_install_failed)
}
private fun removePack(context: Context, root: File, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
val pending = File(root, PENDING)
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
FileUtils.writeString(pending, DesktopPack.info(root)!!.tag)
check(pending.isFile) { "Could not reserve desktop removal" }
if (marker.exists() && !marker.delete()) error("Could not clear desktop marker")
val removing = context.getString(R.string.desktop_removing)
for ((_, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
DesktopFileRemoval.safeFile(root, path)?.takeIf { isRegularFile(it) }?.delete()
}
for (path in DESKTOP_STATE) DesktopPack.inside(root, path)?.let(FileUtils::delete)
val result = if (DesktopPack.installed(root)) DesktopPack.remove(root) { listener.onProgress(removing, it) } else null
DesktopCatalog.discardDesktopInstall(context)
if (!pending.delete()) error("Could not finish desktop removal")
Log.i("DesktopRemoval", "pack: $result")
listener.onProgress(context.getString(R.string.desktop_removed), 100)
null
} catch (e: Exception) {
Log.e("DesktopRemoval", "remove", e)
e.message ?: context.getString(R.string.deskpkg_install_failed)
}
private val DESKTOP_STATE = listOf("var/lib/PackageKit", "var/cache/swcatalog")
private fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)
}
@@ -43,7 +43,7 @@ object GuestCommand {
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
@@ -113,12 +113,16 @@ public final class LinuxRuntime {
}
public static boolean isInstalled(Context context) {
return hasRootfs(context) && (patchedProot(context) || rootfsProotReady(context));
}
/** The rootfs alone: enough to read the game library, while the proot pack can still be downloading. */
public static boolean hasRootfs(Context context) {
if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false;
File root = rootDir(context);
return new File(root, "usr/bin/gamescope").isFile()
return (new File(root, "usr/bin/gamescope").isFile() || new File(root, "usr/games/gamescope").isFile())
&& (new File(root, SESSION_SCRIPT.substring(1)).isFile()
|| new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile())
&& (patchedProot(context) || rootfsProotReady(context));
|| new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile());
}
public static File vulkanIcd(Context context) {
@@ -341,9 +345,9 @@ public final class LinuxRuntime {
}
public static String cpuTempSource() {
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(thermalZones()).entrySet()) {
File temp = new File(e.getValue(), "temp");
if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) return temp.getPath();
for (File zone : cpuTempZones()) {
File temp = new File(zone, "temp");
if (temp.canRead()) return temp.getPath();
}
return null;
}
@@ -353,7 +357,7 @@ public final class LinuxRuntime {
return firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
}
private static String gpuLoadSource() {
public static String gpuLoadSource() {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
return firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load");
}
@@ -367,7 +371,7 @@ public final class LinuxRuntime {
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
{ddrTempSource(), "/sys/class/thermal/thermal_zone22/temp"},
};
for (String[] stat : stats) {
boolean fed = false;
@@ -384,6 +388,36 @@ public final class LinuxRuntime {
return path != null ? path : "none";
}
private static String ddrTempSource() {
return thermalZone("ddr");
}
// MangoHud reads the GPU and DDR temperatures from these zone numbers (bindAdrenoStats). On phones
// where they are CPU zones, their type follows the temperature put there, so they stay out of the
// CPU average.
private static java.util.Map<String, String> replacedZoneTypes() {
java.util.Map<String, String> replaced = new java.util.HashMap<>();
if (gpuTempSource() != null) {
replaced.put("thermal_zone28", "gpu-thermal");
replaced.put("thermal_zone26", "gpu-thermal");
}
if (ddrTempSource() != null) replaced.put("thermal_zone22", "ddr-thermal");
return replaced;
}
/** The zones the session's MangoHud averages for the CPU temperature (all typed cpuN-thermal there). */
public static List<File> cpuTempZones() {
java.util.Map<String, String> replaced = replacedZoneTypes();
List<File> zones = new ArrayList<>();
for (java.util.Map.Entry<String, File> zone : new java.util.TreeMap<>(thermalZones()).entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !replaced.containsKey(zone.getValue().getName())) {
zones.add(zone.getValue());
}
}
return zones;
}
private static void bindCpuTemps(List<String> cmd, File root) {
File name = new File(root, "etc/droiddeck/cpu-thermal-type");
try {
@@ -391,9 +425,18 @@ public final class LinuxRuntime {
} catch (IOException e) {
return;
}
java.util.Map<String, String> replaced = replacedZoneTypes();
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
String other = replaced.get(zone.getValue().getName());
if (other != null) {
File typeFile = new File(root, "etc/droiddeck/" + other + "-type");
try {
if (!typeFile.isFile()) Files.write(typeFile.toPath(), (other + "\n").getBytes(StandardCharsets.US_ASCII));
bind(cmd, typeFile.getPath() + ":" + new File(zone.getValue(), "type").getPath());
} catch (IOException ignored) {
}
} else if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
}
}
@@ -448,12 +491,72 @@ public final class LinuxRuntime {
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
}
public static void writeAccounts(Context context) throws IOException {
public static synchronized void writeAccounts(Context context) throws IOException {
File root = rootDir(context);
int uid = Process.myUid();
Files.write(new File(root, "etc/passwd").toPath(),
("root:x:" + uid + ":" + uid + ":root:/root:/bin/bash\n").getBytes(StandardCharsets.UTF_8));
Files.write(new File(root, "etc/group").toPath(),
("root:x:" + uid + ":\n").getBytes(StandardCharsets.UTF_8));
mergeAccounts(new File(root, "etc/passwd"), 2,
"root:x:" + uid + ":" + uid + ":root:/root:/bin/bash", "root:x:0:0:root:/root:/bin/bash", uid);
mergeAccounts(new File(root, "etc/group"), 2,
"root:x:" + uid + ":", "root:x:0:", uid);
File group = new File(root, "etc/group");
String groups = new String(Files.readAllBytes(group.toPath()), StandardCharsets.UTF_8);
writeIfChanged(group, groups, withStorageGroups(groups));
}
private static final String[][] STORAGE_GROUPS = {
{"sdcard_rw", "1015"}, {"media_rw", "1023"}, {"everybody", "9997"}};
static String withStorageGroups(String groups) {
StringBuilder out = new StringBuilder();
List<String> present = new ArrayList<>();
for (String line : groups.split("\n")) {
if (line.trim().isEmpty()) continue;
String[] fields = line.split(":", -1);
if (fields.length >= 4) {
for (String[] storage : STORAGE_GROUPS) {
if (fields[0].equals(storage[0])) present.add(storage[0]);
if (!fields[2].equals(storage[1])) continue;
present.add(storage[0]);
if (!java.util.Arrays.asList(fields[3].split(",")).contains("root")) {
fields[3] = fields[3].isEmpty() ? "root" : fields[3] + ",root";
line = String.join(":", fields);
}
}
}
out.append(line).append('\n');
}
for (String[] storage : STORAGE_GROUPS) {
if (!present.contains(storage[0])) {
out.append(storage[0]).append(":x:").append(storage[1]).append(":root\n");
}
}
return out.toString();
}
static String mergedAccounts(String existing, int idField, String own, String zero, int uid) {
StringBuilder out = new StringBuilder(own).append('\n').append(zero).append('\n');
if (existing == null) return out.toString();
String ownId = Integer.toString(uid);
for (String line : existing.split("\n")) {
if (line.trim().isEmpty()) continue;
String[] fields = line.split(":", -1);
if (fields.length <= idField) continue;
if (fields[0].equals("root") || fields[idField].equals("0") || fields[idField].equals(ownId)) continue;
out.append(line).append('\n');
}
return out.toString();
}
private static void mergeAccounts(File file, int idField, String own, String zero, int uid) throws IOException {
String existing = file.isFile() ? new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8) : null;
writeIfChanged(file, existing, mergedAccounts(existing, idField, own, zero, uid));
}
private static void writeIfChanged(File file, String existing, String merged) throws IOException {
if (merged.equals(existing)) return;
File staged = File.createTempFile(file.getName(), ".staged", file.getParentFile());
Files.write(staged.toPath(), merged.getBytes(StandardCharsets.UTF_8));
Files.move(staged.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -35,7 +35,7 @@ public final class LinuxRuntimeInstaller {
private static final String TAG = "LinuxRuntimeInstaller";
public static final String CATALOG_URL =
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs-ubuntu.json";
private static final String VERSION_FILE = ".version";
@@ -88,8 +88,20 @@ public final class LinuxRuntimeInstaller {
return v == null ? null : v.trim();
}
private static String catalogUrl() {
if (!com.droiddeck.launcher.BuildConfig.DEBUG) return CATALOG_URL;
try {
Object value = Class.forName("android.os.SystemProperties").getMethod("get", String.class)
.invoke(null, "debug.droiddeck.linuxfs");
if (value instanceof String && ((String) value).startsWith("http")) return (String) value;
} catch (ReflectiveOperationException e) {
Log.w(TAG, "catalog override: " + e);
}
return CATALOG_URL;
}
public static Release fetchRelease() {
String body = Downloader.downloadString(CATALOG_URL);
String body = Downloader.downloadString(catalogUrl());
if (body == null || body.isEmpty()) return null;
try {
JSONObject json = new JSONObject(body);
@@ -292,7 +304,7 @@ public final class LinuxRuntimeInstaller {
recoverInterruptedSwap(root, staging, old);
if (!discard(staging, null) || !staging.mkdirs()) return false;
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
if (!extract(context, archive, staging, root, listener)) {
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -346,7 +358,11 @@ public final class LinuxRuntimeInstaller {
return false;
}
new File(root, CARRY_MARKER).delete();
try { RuntimeFileTree.dropStaleState(root); }
catch (IOException e) { Log.w(TAG, "could not clear caches left by the previous runtime", e); }
discard(old, null);
try { com.droiddeck.launcher.session.SessionFiles.INSTANCE.stage(context, root, false); }
catch (Exception e) { Log.w(TAG, "could not stage the session files into the new runtime", e); }
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
Log.e(TAG, "install", e);
@@ -513,6 +529,10 @@ public final class LinuxRuntimeInstaller {
// Packages extract over the live runtime and their archives list a link before the file it
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
return extract(context, archive, destination, destination, listener);
}
static boolean extract(Context context, File archive, File destination, File linkRoot, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
@@ -537,7 +557,7 @@ public final class LinuxRuntimeInstaller {
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
File target = hardLinkTarget(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
@@ -548,11 +568,11 @@ public final class LinuxRuntimeInstaller {
try {
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
if (!l2sLink(target, part, destination, linkRoot) && target.isFile()) {
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
if (Files.exists(part.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) place(part, file);
} else if (entry.isFile()) {
links.remove(file);
File part = partFile(file);
@@ -589,6 +609,67 @@ public final class LinuxRuntimeInstaller {
}
}
static File hardLinkTarget(File destination, String name) throws IOException {
java.nio.file.Path path = java.nio.file.Paths.get(name).normalize();
java.nio.file.Path last = path.getFileName();
if (last == null || last.toString().isEmpty() || last.toString().equals("..")) return null;
java.nio.file.Path parent = path.getParent();
File dir = parent == null ? destination : ArchivePaths.inside(destination, parent.toString());
return dir == null ? null : new File(dir, last.toString());
}
private static final String L2S = ".l2s.";
static boolean l2sLink(File target, File link) {
return l2sLink(target, link, null, null);
}
static boolean l2sLink(File target, File link, File tree, File linkTree) {
java.nio.file.Path here = tree == null ? null : tree.toPath().toAbsolutePath().normalize();
java.nio.file.Path there = linkTree == null ? here : linkTree.toPath().toAbsolutePath().normalize();
java.util.function.UnaryOperator<java.nio.file.Path> shown = path -> {
java.nio.file.Path p = path.toAbsolutePath().normalize();
return here == null || !p.startsWith(here) ? p : there.resolve(here.relativize(p));
};
java.util.function.UnaryOperator<java.nio.file.Path> actual = path -> {
java.nio.file.Path p = path.toAbsolutePath().normalize();
return there == null || !p.startsWith(there) ? p : here.resolve(there.relativize(p));
};
try {
java.nio.file.Path original = target.toPath().toAbsolutePath().normalize();
java.nio.file.Path intermediate;
if (Files.isSymbolicLink(original)) {
intermediate = actual.apply(Files.readSymbolicLink(original));
if (!intermediate.getFileName().toString().startsWith(L2S) || !Files.isSymbolicLink(intermediate)) return false;
java.nio.file.Path fin = actual.apply(Files.readSymbolicLink(intermediate));
String name = fin.getFileName().toString();
int count = Integer.parseInt(name.substring(name.length() - 4)) + 1;
java.nio.file.Path next = fin.resolveSibling(name.substring(0, name.length() - 4) + String.format(java.util.Locale.ROOT, "%04d", count));
Files.move(fin, next, StandardCopyOption.ATOMIC_MOVE);
Files.delete(intermediate);
Files.createSymbolicLink(intermediate, shown.apply(next));
} else {
if (!Files.isRegularFile(original, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false;
java.nio.file.Path dir = original.getParent();
String base = L2S + original.getFileName();
int suffix = 1;
do {
intermediate = dir.resolve(base + String.format(java.util.Locale.ROOT, "%04d", suffix++));
} while (Files.exists(intermediate, java.nio.file.LinkOption.NOFOLLOW_LINKS) && suffix < 1000);
java.nio.file.Path fin = dir.resolve(intermediate.getFileName() + ".0002");
Files.move(original, fin, StandardCopyOption.ATOMIC_MOVE);
Files.createSymbolicLink(intermediate, shown.apply(fin));
Files.createSymbolicLink(original, shown.apply(intermediate));
}
Files.deleteIfExists(link.toPath());
Files.createSymbolicLink(link.toPath(), shown.apply(intermediate));
return true;
} catch (IOException | RuntimeException e) {
Log.w(TAG, "l2s link " + link + " -> " + target + ": " + e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
@@ -0,0 +1,201 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.net.LocalServerSocket
import android.net.LocalSocket
import android.os.ParcelFileDescriptor
import android.os.Process
import android.util.Log
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicInteger
object RootSpawner {
private const val TAG = "RootSpawner"
fun socket(context: Context): String = "${context.packageName}.root"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
@Synchronized
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val name = socket(app)
val socket = try {
LocalServerSocket(name)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $name", e)
return
}
server = socket
Thread({
while (true) {
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
Thread({ serve(app, client) }, "root-${seq.get() + 1}").apply { isDaemon = true; start() }
}
}, "root-server").apply { isDaemon = true; start() }
}
private fun serve(context: Context, client: LocalSocket) {
val id = seq.incrementAndGet()
val received = ArrayList<FileDescriptor>()
client.use {
val out = DataOutputStream(client.outputStream)
try {
if (client.peerCredentials.uid != Process.myUid()) {
Log.w(TAG, "refused root for uid ${client.peerCredentials.uid}")
return
}
val input = DataInputStream(client.inputStream)
val kind = input.readUnsignedByte()
client.ancillaryFileDescriptors?.let { received.addAll(it) }
val body = ByteArray(input.readInt()).also { input.readFully(it) }
client.ancillaryFileDescriptors?.let { received.addAll(it) }
if (kind != 'R'.code) return
run(context, JSONObject(String(body, Charsets.UTF_8)), received, client, input, out, id)
} catch (e: Exception) {
Log.e(TAG, "root $id", e)
runCatching {
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
frame(out, 'X', "1".toByteArray())
}
} finally {
received.forEach { fd -> runCatching { android.system.Os.close(fd) } }
}
}
}
private class Stdio(val redirect: ProcessBuilder.Redirect?, val fd: FileDescriptor?, val dup: ParcelFileDescriptor?)
private fun stdio(fd: FileDescriptor?, write: Boolean): Stdio {
if (fd == null) return Stdio(null, null, null)
val dup = try { ParcelFileDescriptor.dup(fd) } catch (e: IOException) { return Stdio(null, fd, null) }
val path = File("/proc/self/fd/${dup.fd}")
val opens = try {
if (write) FileOutputStream(path, true).close() else FileInputStream(path).close()
true
} catch (e: IOException) { false }
if (!opens) return Stdio(null, fd, dup)
val redirect = if (write) ProcessBuilder.Redirect.appendTo(path) else ProcessBuilder.Redirect.from(path)
return Stdio(redirect, fd, dup)
}
private fun pump(from: InputStream, to: OutputStream, name: String) =
Thread({
val buffer = ByteArray(16384)
try {
while (true) {
val n = from.read(buffer)
if (n < 0) break
to.write(buffer, 0, n)
to.flush()
}
} catch (e: IOException) {
} finally {
runCatching { to.close() }
}
}, name).apply { isDaemon = true; start() }
private fun run(context: Context, request: JSONObject, fds: List<FileDescriptor>, client: LocalSocket,
input: DataInputStream, out: DataOutputStream, id: Int) {
val root = LinuxRuntime.rootDir(context)
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
if (argv.isEmpty()) throw IOException("no command")
val cwd = request.optString("cwd", "/root").ifEmpty { "/root" }
val cmd = LinuxRuntime.prootPrefix(context, root, cwd, true)
cmd.add(1, "--link2symlink")
LinuxRuntime.lastBinds(context).filterNot { it.startsWith("/proc/self/fd/") }.forEach { cmd.add("-b"); cmd.add(it) }
cmd.add("/usr/bin/env")
cmd.add("-i")
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> cmd.add("$k=${e.getString(k)}") } }
cmd.addAll(argv)
val builder = ProcessBuilder(cmd).directory(root)
builder.environment().apply {
clear()
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val streams = listOf(stdio(fds.getOrNull(0), false), stdio(fds.getOrNull(1), true), stdio(fds.getOrNull(2), true))
try {
builder.redirectInput(streams[0].redirect ?: ProcessBuilder.Redirect.PIPE)
builder.redirectOutput(streams[1].redirect ?: ProcessBuilder.Redirect.PIPE)
builder.redirectError(streams[2].redirect ?: ProcessBuilder.Redirect.PIPE)
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
Log.i(TAG, "root $id pid $pid: ${argv.joinToString(" ")}")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
val pumps = ArrayList<Thread>()
if (streams[0].redirect == null) {
val fd = streams[0].fd
if (fd != null) pumps.add(pump(FileInputStream(fd), process.outputStream, "root-$id-in").also { it.isDaemon = true })
else runCatching { process.outputStream.close() }
}
if (streams[1].redirect == null) pumps.add(pump(process.inputStream,
streams[1].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-out"))
if (streams[2].redirect == null) pumps.add(pump(process.errorStream,
streams[2].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-err"))
Thread({
try {
while (true) {
val kind = input.readUnsignedByte()
val body = ByteArray(input.readInt()).also { input.readFully(it) }
if (kind == 'S'.code) String(body).trim().toIntOrNull()?.let { signal(pid, it) }
}
} catch (e: IOException) {
}
if (process.isAlive) {
Log.i(TAG, "root $id: its caller is gone; stopping it")
signal(pid, android.system.OsConstants.SIGHUP)
android.os.Process.sendSignal(pid, android.system.OsConstants.SIGQUIT)
}
}, "root-$id-watch").apply { isDaemon = true; start() }
val status = process.waitFor()
pumps.filter { it.name.endsWith("-out") || it.name.endsWith("-err") }.forEach { it.join(2000) }
OrphanReaper.release(pid)
Log.i(TAG, "root $id exited $status")
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
} finally {
streams.forEach { s -> runCatching { s.dup?.close() } }
}
}
private fun signal(top: Int, signal: Int) {
val parents = HashMap<Int, Int>()
File("/proc").listFiles()?.forEach { dir ->
val pid = dir.name.toIntOrNull() ?: return@forEach
val stat = try { File(dir, "stat").readText() } catch (e: IOException) { return@forEach }
val fields = stat.substringAfterLast(')').trim().split(' ')
fields.getOrNull(1)?.toIntOrNull()?.let { parents[pid] = it }
}
val tree = HashSet<Int>()
var grew = true
while (grew) {
grew = false
for ((pid, parent) in parents) {
if (pid !in tree && (parent == top || parent in tree)) { tree.add(pid); grew = true }
}
}
tree.forEach { runCatching { android.os.Process.sendSignal(it, signal) } }
}
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
out.writeByte(kind.code)
out.writeInt(payload.size)
out.write(payload)
out.flush()
}
}
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.BuildConfig
object RuntimeAutoUpdate {
private const val PREFS = "runtime-auto-update"
const val MAX_ATTEMPTS = 3
const val RETRY_MS = 10 * 60_000L
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean =
installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
fun key(appVersion: Int, release: String) = "$appVersion:$release"
fun claim(context: Context, installed: String?, release: String?, now: Long = System.currentTimeMillis()): Boolean {
if (release.isNullOrEmpty()) return false
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val key = key(BuildConfig.VERSION_CODE, release)
synchronized(this) {
val attempts = prefs.getInt(key, 0)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
val edit = prefs.edit()
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) }
edit.putInt(key, attempts + 1).putLong("$key@at", now).apply()
return true
}
}
}
@@ -80,7 +80,7 @@ public final class RuntimeFileTree {
* be carried.
*/
static java.util.List<String> userPaths(File tree) throws IOException {
java.util.List<String> paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps"));
java.util.List<String> paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps", "opt/droiddeck/wincomponents"));
for (String n : children(new File(tree, "usr/local/share/applications"))) {
if (n.endsWith(".desktop") && (n.startsWith("droiddeck-app-") || n.startsWith("droiddeck-appimage-")))
paths.add("usr/local/share/applications/" + n);
@@ -109,10 +109,39 @@ public final class RuntimeFileTree {
static java.util.List<String> existingUserPaths(File from, File to) throws IOException {
java.util.List<String> paths = new java.util.ArrayList<>();
for (String rel : userPaths(from, to)) if (exists(safePath(from, rel).toPath())) paths.add(rel);
for (String rel : userPaths(from, to)) {
if (!exists(safePath(from, rel).toPath())) continue;
if (rel.startsWith("usr/share/applications/") && exists(safePath(to, rel).toPath())) continue;
paths.add(rel);
}
return paths;
}
private static final String[] STALE_HOME_STATE = {
".local/share/droiddeck/pacman", ".cache/droiddeck/pacman-restore.log",
".cache/appstream", ".cache/discover", ".cache/fontconfig", ".cache/kwin", ".cache/ksplash",
".cache/ksvg-elements", ".cache/plasmashell", ".cache/kwinsession.kcache", ".cache/icon-cache.kcache",
};
private static final String[] STALE_HOME_CACHE_PREFIXES = {
"mesa_shader_cache", "ksycoca", "plasma_theme_", "qtshadercache", "event-sound-cache.tdb.",
};
static void dropStaleState(File runtimeRoot) throws IOException {
File home = new File(runtimeRoot, "root");
java.util.List<String> stale = new java.util.ArrayList<>(java.util.Arrays.asList(STALE_HOME_STATE));
for (String n : children(new File(home, ".cache"))) {
for (String prefix : STALE_HOME_CACHE_PREFIXES) {
if (n.startsWith(prefix)) { stale.add(".cache/" + n); break; }
}
}
IOException failed = null;
for (String rel : stale) {
try { delete(safePath(home, rel), null); }
catch (IOException e) { if (failed == null) failed = e; else failed.addSuppressed(e); }
}
if (failed != null) throw failed;
}
private static java.util.List<String> children(File dir) throws IOException {
java.util.List<String> names = new java.util.ArrayList<>();
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir.toPath())) {
@@ -11,14 +11,15 @@ object RuntimeHealth {
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
/** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List<String> {
val lib = File(root, "usr/lib")
val names = lib.list() ?: return emptyList()
return names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
fun brokenLibraryLinks(root: File): List<String> =
listOf("usr/lib", "usr/lib/aarch64-linux-gnu").flatMap { dir ->
val lib = File(root, dir)
val names = lib.list() ?: return@flatMap emptyList()
names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}.map { if (dir == "usr/lib") it else "aarch64-linux-gnu/$it" }
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
@@ -36,6 +36,9 @@ import kotlin.math.abs
* 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.
*
* Part of the Performance boost switch, on by default only where it was measured ([measuredHere]).
* Another phone's HAL may boost differently, or not at all.
*/
object GameThreadHints {
private const val TAG = "GameThreadHints"
@@ -46,21 +49,23 @@ object GameThreadHints {
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
internal const val PACE_WINDOW_S = 3
internal 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")
/** The Odin 3 (CQ8725S), where the boost was measured. */
val measuredHere: Boolean get() = Build.VERSION.SDK_INT >= 31 && Build.SOC_MODEL == "CQ8725S"
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"
if (!SessionPrefs.performanceBoost(context)) return "game thread hints: off"
if (Build.VERSION.SDK_INT < 31) return "game thread hints: needs Android 12"
val hm = context.getSystemService(PerformanceHintManager::class.java)
?: return "game thread hints: no PerformanceHintManager"
val pm = context.getSystemService(PowerManager::class.java)
@@ -21,7 +21,7 @@ class GpuMemComponent(private val file: File, private val root: File) : SessionP
thread = Thread({
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (running && OverlayShown.check(root)) sample()?.let { write(it) }
if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.FusionHudHost.showing)) sample()?.let { write(it) }
}
}, "gpu-mem").apply { isDaemon = true; start() }
}
@@ -1,178 +0,0 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
class PerfHud(context: Context) {
var text by mutableStateOf("")
private set
var graph by mutableStateOf(FloatArray(0))
private set
private val context = context.applicationContext
private val frames = AtomicInteger()
private var lastTick = 0L
private var lastFps = 0f
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val samples = FloatArray(GRAPH)
private var graphBase = 0L
private var graphSeen = 0L
@Volatile private var showFps = true
@Volatile private var showBattery = true
@Volatile private var showGraph = true
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context)
&& !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
private val anyPart: Boolean
get() = showFps || showBattery || showGraph
var onPresentingWindowChanged: (() -> Unit)? = null
private var lastWindow: String? = null
private val listener = object : WaylandCompositor.GameListener {
override fun onGameSurface(window: String?, gpuName: String?) {
if (window != null && window != lastWindow) {
lastWindow = window
handler.post { onPresentingWindowChanged?.invoke() }
}
}
override fun onGameFrame() {
frames.incrementAndGet()
}
}
private val tick = object : Runnable {
override fun run() {
if (!running) return
val now = SystemClock.elapsedRealtime()
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
lastTick = now
lastFps = frames.getAndSet(0) / dt
text = readout(lastFps)
if (showGraph) publishGraph() else clearGraph()
handler.postDelayed(this, TICK_MS)
}
}
fun refresh() {
readOptions()
if (!enabled || !anyPart) {
stop()
return
}
if (!running) start() else {
if (!showGraph) clearGraph() else publishGraph()
text = readout(lastFps)
}
}
fun start() {
readOptions()
if (!enabled || !anyPart || running) return
running = true
lastTick = SystemClock.elapsedRealtime()
frames.set(0)
graphBase = shownTotal()
graphSeen = graphBase
WaylandCompositor.setGameListener(listener)
handler.postDelayed(tick, TICK_MS)
}
fun stop() {
running = false
handler.removeCallbacks(tick)
WaylandCompositor.clearGameListener(listener)
text = ""
graph = FloatArray(0)
}
private fun readOptions() {
showFps = SessionPrefs.hudShowFps(context)
showBattery = SessionPrefs.hudShowBattery(context)
showGraph = SessionPrefs.hudShowGraph(context)
}
private fun readout(fpsValue: Float): String {
val parts = ArrayList<String>(2)
if (showFps) parts.add(line(fpsValue))
if (showBattery) batteryCelsius(readBatteryDeci())?.let { parts.add(it) }
return parts.joinToString("\n")
}
private fun readBatteryDeci(): Int {
val sticky = runCatching {
context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}.getOrNull() ?: return 0
return sticky.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0)
}
private fun shownTotal(): Long = try { WaylandCompositor.nativeHudFrameTimes(samples) } catch (t: Throwable) { 0L }
private fun clearGraph() {
graphBase = shownTotal()
graphSeen = graphBase
if (graph.isNotEmpty()) graph = FloatArray(0)
}
private fun publishGraph() {
val total = shownTotal()
if (total == graphSeen && graph.isNotEmpty()) return
graphSeen = total
graph = recent(samples, total, graphBase)
}
private fun line(base: Float): String {
val mode = FrameGen.mode(context)
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
val name = if (lsfg) "LSFG" else "DD-FG"
val label = FrameGen.label(context, mode)
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 }
return when {
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
stats != null && stats[3] > 1f -> String.format(Locale.US, "%s %.0f → %.0f fps", label, base, stats[3])
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
}
}
private fun fps(value: Float) = String.format(Locale.US, "%.0f fps", value)
companion object {
private const val TICK_MS = 1_000L
private const val GRAPH = 128
internal const val GRAPH_MAX_MS = 50f
internal fun recent(samples: FloatArray, total: Long, base: Long): FloatArray {
val filled = minOf(total, samples.size.toLong()).toInt()
val shown = minOf(filled.toLong(), (total - base).coerceAtLeast(0L)).toInt()
if (shown < 2) return FloatArray(0)
return FloatArray(shown) { samples[filled - shown + it].coerceIn(0f, GRAPH_MAX_MS) }
}
internal fun batteryCelsius(deci: Int): String? =
if (deci <= 0) null else String.format(Locale.US, "%.1f°C", deci / 10f)
}
}
@@ -0,0 +1,193 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.os.PowerManager
import android.util.Log
import com.android.dx.command.dexer.DxContext
import com.android.dx.command.dexer.Main
import com.droiddeck.launcher.wayland.WaylandCompositor
import dalvik.system.DexClassLoader
import java.io.ByteArrayOutputStream
import java.io.File
import java.lang.reflect.Method
import java.util.zip.ZipFile
/**
* Samsung's Galaxy Performance SDK (com.samsung.sdk.sperf): CPU, GPU and memory-bus clock floors from
* Samsung's own service. Its licence does not allow passing the jar on, so a Samsung owner downloads
* it from [DOWNLOAD_URL] and imports it; it is turned into dex on the phone and loaded from there.
*
* The presets are renewed every [RENEW_S] seconds with a [HOLD_MS] timeout, so Samsung's service
* lets go on its own if the app dies.
*/
object SamsungPerf {
enum class ImportResult { IMPORTED, INVALID, FAILED }
private const val TAG = "SamsungPerf"
const val DOWNLOAD_URL = "https://developer.samsung.com/galaxy-performance/download.html"
private const val SPERF = "com.samsung.sdk.sperf"
private val REQUIRED = listOf("SPerf", "PerformanceManager").map { "${SPERF.replace('.', '/')}/$it.class" }
private const val PRESETS = 64 or 128 or 256 // PerformanceManager.PRESET_TYPE_CPU | _GPU | _BUS
private const val HOLD_MS = 5000
private const val RENEW_S = 2
val samsung: Boolean get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
fun offered(context: Context) = samsung || SessionPrefs.samsungPerfAnyPhone(context)
fun imported(context: Context) = dexFile(context).isFile
private fun dir(context: Context) = File(context.filesDir, "samsung-perf")
private fun dexFile(context: Context) = File(dir(context), "perfsdk.dex.jar")
@Synchronized
fun import(context: Context, source: File): ImportResult {
val dir = dir(context).apply { mkdirs() }
val jar = File(dir, "perfsdk.jar.staged")
val dex = File(dir, "perfsdk.dex.jar.staged")
try {
if (!extractJar(source, jar)) return ImportResult.INVALID
val log = ByteArrayOutputStream()
val dx = DxContext(log, log)
val args = Main.Arguments(dx).apply {
fileNames = arrayOf(jar.path)
outName = dex.path
jarOutput = true
minSdkVersion = 26
makeOptionsObjects()
}
val status = Main(dx).runDx(args)
if (status != 0 || !dex.isFile) {
Log.w(TAG, "dx exit $status: $log")
return ImportResult.FAILED
}
val target = dexFile(context)
target.setWritable(true)
target.delete()
if (!dex.renameTo(target)) return ImportResult.FAILED
target.setReadOnly()
sdk = null
unusable = false
Log.i(TAG, "SDK from $source ready (${target.length()} bytes of dex)")
return ImportResult.IMPORTED
} catch (t: Throwable) {
Log.w(TAG, "could not import $source", t)
return ImportResult.FAILED
} finally {
jar.delete()
dex.delete()
}
}
/** Samsung's zip also holds the sample app's different perfsdk.jar; Libs/ is the SDK's own. */
internal fun extractJar(source: File, target: File): Boolean = try {
ZipFile(source).use { zip -> extractJar(zip, source, target) }
} catch (_: java.util.zip.ZipException) {
false
}
private fun extractJar(zip: ZipFile, source: File, target: File): Boolean {
if (REQUIRED.all { zip.getEntry(it) != null }) {
source.copyTo(target, overwrite = true)
return true
}
val jars = zip.entries().asSequence()
.filter { !it.isDirectory && it.name.endsWith(".jar", ignoreCase = true) }
.sortedByDescending { it.name.contains("Libs/") }
.toList()
for (entry in jars) {
zip.getInputStream(entry).use { input -> target.outputStream().use { input.copyTo(it) } }
val found = runCatching { ZipFile(target).use { inner -> REQUIRED.all { inner.getEntry(it) != null } } }.getOrDefault(false)
if (found) return true
}
return false
}
private class Sdk(val manager: Any, val start: Method, val stop: Method)
private var sdk: Sdk? = null
private var unusable = false
/** SPerf.initialize is false where Samsung's service is missing. */
@Synchronized
private fun load(context: Context): Sdk? {
sdk?.let { return it }
if (unusable || !imported(context)) return null
try {
val loader = DexClassLoader(dexFile(context).path, context.codeCacheDir.path, null, context.classLoader)
val sperf = loader.loadClass("$SPERF.SPerf")
runCatching { sperf.getMethod("setDebugModeEnabled", Boolean::class.javaPrimitiveType).invoke(null, false) }
if (sperf.getMethod("initialize", Context::class.java).invoke(null, context.applicationContext) != true) {
Log.i(TAG, "SDK loaded, but this phone does not support it")
unusable = true
return null
}
val pm = loader.loadClass("$SPERF.PerformanceManager")
val manager = pm.getMethod("getInstance").invoke(null) ?: return null
val int = Int::class.javaPrimitiveType
return Sdk(manager, pm.getMethod("start", int, int), pm.getMethod("stop")).also { sdk = it }
} catch (t: Throwable) {
Log.w(TAG, "SDK not usable", t)
unusable = true
return null
}
}
private var worker: Thread? = null
/** At a session's start. The line is for the log. */
@Synchronized
fun start(context: Context): String {
if (!offered(context)) return "samsung boost: not a Samsung phone"
if (!SessionPrefs.performanceBoost(context)) return "samsung boost: off"
if (!imported(context)) return "samsung boost: SDK not imported"
val app = context.applicationContext
worker?.interrupt()
worker = Thread({ run(app) }, "samsung-boost").apply { isDaemon = true; start() }
return "samsung boost: watching"
}
/** At a session's end. */
@Synchronized
fun stop() {
worker?.interrupt()
worker = null
}
private fun run(context: Context) {
val sdk = load(context) ?: return
val pm = context.getSystemService(PowerManager::class.java)
val frames = ArrayDeque<Long>()
var pacedFor = 0
var boosting = false
var tick = 0
try {
while (!Thread.currentThread().isInterrupted) {
frames.addLast(WaylandCompositor.gameFrameCount())
if (frames.size > GameThreadHints.PACE_WINDOW_S + 1) frames.removeFirst()
val fps = (frames.last() - frames.first()).toDouble() / (frames.size - 1).coerceAtLeast(1)
val paced = frames.size > GameThreadHints.PACE_WINDOW_S && GameThreadHints.holdsPace(fps, SessionState.refreshHz.toDouble())
pacedFor = if (paced) pacedFor + 1 else 0
val hot = Build.VERSION.SDK_INT >= 29 && pm.currentThermalStatus >= PowerManager.THERMAL_STATUS_SEVERE
val want = fps >= 1 && !pm.isPowerSaveMode && !hot && pacedFor < GameThreadHints.PACE_LEAVE_S
if (want && (!boosting || tick % RENEW_S == 0)) {
val result = sdk.start.invoke(sdk.manager, PRESETS, HOLD_MS)
if (!boosting) Log.i(TAG, "boosting (game ${fps.toInt()} fps, start $result)")
} else if (!want && boosting) {
sdk.stop.invoke(sdk.manager)
Log.i(TAG, "let go (game ${fps.toInt()} fps${if (hot) ", hot" else ""}${if (paced) ", holding pace" else ""})")
}
boosting = want
tick++
Thread.sleep(1000)
}
} catch (_: InterruptedException) {
} catch (t: Throwable) {
Log.w(TAG, "stopped", t)
} finally {
if (boosting) runCatching { sdk.stop.invoke(sdk.manager) }
}
}
}
@@ -25,6 +25,8 @@ object SessionFiles {
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
"usr/local/bin/droiddeck-library-perms" to "usr/local/bin/droiddeck-library-perms",
"usr/local/bin/droiddeck-ime" to "usr/local/bin/droiddeck-ime",
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
@@ -32,7 +34,7 @@ object SessionFiles {
/** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
fun stage(context: Context, root: File, startsSession: Boolean = false) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
if (DesktopCatalog.desktopInstalled(root) && !DesktopCatalog.desktopFromPack(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
WinComponents.publish(context)
@@ -70,10 +72,21 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-launch" to "usr/local/bin/droiddeck-steam-launch",
"usr/local/bin/droiddeck-desktop-games" to "usr/local/bin/droiddeck-desktop-games",
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-downloads" to "usr/local/bin/droiddeck-downloads",
"usr/local/bin/droiddeck-root" to "usr/local/bin/droiddeck-root",
"usr/local/bin/droiddeck-text" to "usr/local/bin/droiddeck-text",
"usr/local/bin/droiddeck-snapd" to "usr/local/bin/droiddeck-snapd",
"usr/local/bin/droiddeck-emulators" to "usr/local/bin/droiddeck-emulators",
"usr/local/bin/droiddeck-snap-run" to "usr/local/bin/droiddeck-snap-run",
"usr/local/bin/droiddeck-snap-confine" to "usr/local/bin/droiddeck-snap-confine",
"usr/local/bin/droiddeck-snapctl" to "usr/local/bin/droiddeck-snapctl",
"usr/local/bin/droiddeck-systemd-run" to "usr/local/bin/systemd-run",
"usr/local/bin/droiddeck-snap-cli" to "usr/local/bin/snap",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
"usr/local/bin/droiddeck-gog-maintenance" to "usr/local/bin/droiddeck-gog-maintenance",
"usr/local/bin/droiddeck-battlenet" to "usr/local/bin/droiddeck-battlenet",
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
@@ -234,6 +247,7 @@ object SessionFiles {
Log.e(TAG, "could not write steam-compatibility-label")
}
writeCjkFontConfig(root, etc)
writeDesktopStrings(context, File(etc, "droiddeck").apply { mkdirs() })
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
@@ -241,6 +255,12 @@ object SessionFiles {
stagedSocket.delete()
Log.e(TAG, "could not write bwrap-socket")
}
val stagedRoot = File(droiddeckEtc, "root-socket.staged")
if (!FileUtils.writeString(stagedRoot, com.droiddeck.launcher.runtime.RootSpawner.socket(context) + "\n")
|| !stagedRoot.renameTo(File(droiddeckEtc, "root-socket"))) {
stagedRoot.delete()
Log.e(TAG, "could not write root-socket")
}
BootMovie.syncFromSteam(context)
val startupMovie = BootMovie.prepare(context)
@@ -270,6 +290,7 @@ object SessionFiles {
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
)
if (startsSession) disableIdleSleep(home)
if (!done) allDone = false
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
@@ -508,6 +529,43 @@ object SessionFiles {
}
}
private val IDLE_SLEEP_TIMERS = listOf("IdleSuspendBatterySeconds", "IdleSuspendACSeconds")
/**
* Sets Steam's Power > "Sleep after" (battery and plugged in) to Disabled at every session start: Android
* owns the device's sleep, and a client that suspends inside the session only shows a black screen. Dimming stays the user's. Runs
* only before the client starts, which rewrites its config on exit.
*/
internal fun disableIdleSleep(steamHome: File): Boolean {
val config = File(steamHome, "config/config.vdf")
val text = runCatching { config.readText() }.getOrNull() ?: return false
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val steam = findSteamBlock(text) ?: return false
val system = findObject(text, "system", steam.open + 1, steam.close)
val updated = if (system != null) {
val block = text.substring(system.keyStart, system.close + 1)
text.replaceRange(system.keyStart, system.close + 1, IDLE_SLEEP_TIMERS.fold(block) { acc, key -> setScalar(acc, key, "0", newline) })
} else {
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' } + "\t"
val section = (listOf(indent + "\"system\"", indent + "{") +
IDLE_SLEEP_TIMERS.map { "$indent\t\"$it\"\t\t\"0\"" } + (indent + "}")).joinToString(newline) + newline
text.substring(0, lineStart) + section + text.substring(lineStart)
}
val staged = File(config.parentFile, config.name + ".staged")
return try {
if (updated != text) {
staged.writeText(updated)
if (!staged.renameTo(config)) { staged.delete(); return false }
}
true
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not disable Steam's idle sleep", e)
false
}
}
/** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
private fun withCustomization(text: String, path: String, newline: String): String {
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
@@ -585,6 +643,28 @@ object SessionFiles {
* The fonts themselves are bind-mounted by LinuxRuntime.bindSystemFonts() into
* /usr/local/share/fonts/android; here we only need to point fontconfig at them.
*/
private val DESKTOP_STRINGS = listOf(
"desktop_restore_title" to R.string.desktop_restore_title,
"desktop_restore_body" to R.string.desktop_restore_body,
"desktop_restore_done_title" to R.string.desktop_restore_done_title,
"desktop_restore_done_body" to R.string.desktop_restore_done_body,
"desktop_restore_failed_title" to R.string.desktop_restore_failed_title,
"desktop_restore_failed_body" to R.string.desktop_restore_failed_body,
)
private fun writeDesktopStrings(context: Context, dir: File) {
val text = DESKTOP_STRINGS.joinToString("") { (key, id) ->
key + "=" + context.getString(id).replace("\\", "\\\\").replace("%", "%%").replace("\n", "\\n") + "\n"
}
val target = File(dir, "strings")
if (FileUtils.readString(target) == text) return
val staged = File(dir, "strings.staged")
if (!FileUtils.writeString(staged, text) || !staged.renameTo(target)) {
staged.delete()
Log.e(TAG, "could not write desktop strings")
}
}
private fun writeCjkFontConfig(root: File, etc: File) {
try {
val confDir = File(etc, "fonts/conf.d").apply { mkdirs() }
@@ -21,6 +21,8 @@ object SessionPrefs {
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff"
private const val SLOW_USB_DISMISSED = "slowUsbDismissed"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
@@ -84,6 +86,32 @@ object SessionPrefs {
prefs(context).edit().putBoolean("animations", on).apply()
}
fun dualScreenMode(context: Context): String =
prefs(context).getString("dualScreenMode", null)?.takeIf { it in com.droiddeck.launcher.input.DualScreen.modes }
?: com.droiddeck.launcher.input.DualScreen.MODE_AUTO
fun setDualScreenMode(context: Context, mode: String) {
prefs(context).edit().putString("dualScreenMode", mode).apply()
}
fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null)
fun setSecondScreenPanel(context: Context, mode: String) {
prefs(context).edit().putString("secondScreenPanel", mode).apply()
}
fun dualScreenLauncher(context: Context): Boolean = prefs(context).getBoolean("dualScreenLauncher", true)
fun setDualScreenLauncher(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("dualScreenLauncher", on).apply()
}
fun dualScreenSessionControls(context: Context): Boolean = prefs(context).getBoolean("dualScreenSessionControls", true)
fun setDualScreenSessionControls(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply()
}
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
fun setGameStoresEnabled(context: Context, on: Boolean) {
@@ -119,10 +147,34 @@ object SessionPrefs {
}
fun storesView(context: Context): String =
prefs(context).getString("storesView", null)?.takeIf { it in STORES_VIEWS } ?: STORES_VIEWS.first()
prefs(context).getString("storesView", null)?.takeIf { it in STORES_VIEWS } ?: DEFAULT_STORES_VIEW
fun setStoresView(context: Context, view: String) {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else DEFAULT_STORES_VIEW).apply()
}
fun libraryCardLevel(context: Context): Int = prefs(context).getInt("libraryCardLevel", 2)
fun setLibraryCardLevel(context: Context, level: Int) {
prefs(context).edit().putInt("libraryCardLevel", level).apply()
}
fun libraryShowTitle(context: Context): Boolean = prefs(context).getBoolean("libraryShowTitle", true)
fun setLibraryShowTitle(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("libraryShowTitle", show).apply()
}
fun libraryShowSize(context: Context): Boolean = prefs(context).getBoolean("libraryShowSize", true)
fun setLibraryShowSize(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("libraryShowSize", show).apply()
}
fun railLabels(context: Context): Boolean = prefs(context).getBoolean("railLabels", false)
fun setRailLabels(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("railLabels", show).apply()
}
fun libraryShowUninstalled(context: Context): Boolean = prefs(context).getBoolean("libraryShowUninstalled", false)
@@ -147,6 +199,7 @@ object SessionPrefs {
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "capsules", "list")
const val DEFAULT_STORES_VIEW = "capsules"
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
@@ -195,7 +248,7 @@ object SessionPrefs {
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), false)
fun setHudEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(hudKey(context), on).apply()
@@ -233,6 +286,20 @@ object SessionPrefs {
prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply()
}
fun fusionHudConfig(context: Context): String? = prefs(context).getString("fusionHud", null)
fun setFusionHudConfig(context: Context, config: String) {
prefs(context).edit().putString("fusionHud", config).apply()
}
fun fusionHudPosition(context: Context): Pair<Float, Float>? =
prefs(context).getString("fusionHudPos", null)?.split(',')?.mapNotNull { it.toFloatOrNull() }
?.takeIf { it.size == 2 }?.let { it[0] to it[1] }
fun setFusionHudPosition(context: Context, x: Float, y: Float) {
prefs(context).edit().putString("fusionHudPos", "$x,$y").apply()
}
const val HUD_COLOR_MINT = "mint"
val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet")
val HUD_OPACITY_RANGE = 30..100
@@ -249,6 +316,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean("lowLatency", on).apply()
}
fun evdevMouse(context: Context): Boolean = prefs(context).getBoolean("evdevMouse", false)
fun setEvdevMouse(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("evdevMouse", on).apply()
}
fun directCursor(context: Context): Boolean = prefs(context).getBoolean("directCursor", false)
fun setDirectCursor(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("directCursor", on).apply()
}
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
fun setBackActionsInverted(context: Context, inverted: Boolean) {
@@ -363,11 +442,15 @@ object SessionPrefs {
/**
* Shader pre-caching as the launcher last set it, until a session reports it written into the
* client's config.vdf; after that the client's own toggle is the user's again.
* client's config.vdf; after that the client's own toggle is the user's again. A client that
* has never written a config.vdf starts with pre-caching off.
*/
fun pendingShaderPrecache(context: Context): Boolean? {
val prefs = prefs(context)
if (!prefs.contains("shaderPrecache")) return null
if (!prefs.contains("shaderPrecache")) {
if (steamConfig(context).exists()) return null
prefs.edit().putBoolean("shaderPrecache", false).apply()
}
val on = prefs.getBoolean("shaderPrecache", true)
val applied = shaderPrecacheApplied(context)
val written = runCatching { applied.readText().trim() }.getOrNull() ?: return on
@@ -382,9 +465,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean("shaderPrecache", on).apply()
}
private fun steamConfig(context: Context) =
java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf")
fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context)
?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache(
java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf").readText()) }.getOrDefault(true)
steamConfig(context).readText()) }.getOrDefault(true)
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
@@ -529,8 +615,18 @@ 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)
/**
* The performance boost: the game's busiest threads named to the power HAL (GameThreadHints) and,
* on a Samsung phone with Samsung's SDK imported, its clock floors (SamsungPerf).
*/
fun performanceBoost(context: Context): Boolean = prefs(context).getBoolean("performanceBoost", GameThreadHints.measuredHere)
fun setPerformanceBoost(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("performanceBoost", on).apply()
}
/** Hidden: offers the Samsung SDK import on any phone, to test the import where there is no Samsung. */
fun samsungPerfAnyPhone(context: Context): Boolean = prefs(context).getBoolean("samsungPerfAnyPhone", false)
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
@@ -937,21 +1033,34 @@ object SessionPrefs {
if (previous != null && !p.contains("addedAppId:$folderPath")) {
val moved = movedAddedGame(p.all, previous, folderPath)
moved.keys.forEach { edit.remove(it) }
moved.values.forEach { (name, value) ->
when (value) {
is String -> edit.putString(name, value)
is Long -> edit.putLong(name, value)
is Int -> edit.putInt(name, value)
is Boolean -> edit.putBoolean(name, value)
is Float -> edit.putFloat(name, value)
}
}
moved.values.forEach { (name, value) -> edit.putValue(name, value) }
val removed = removedAddedGames(context)
if (previous in removed) edit.putString("removedAddedGames", removed.map { if (it == previous) folderPath else it }.joinToString("\n"))
}
edit.apply()
}
fun inheritAddedGame(context: Context, from: String, to: String) = synchronized(addedGameLock) {
val p = prefs(context)
val all = p.all
val edit = p.edit()
all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.forEach { (name, value) ->
val key = name.removeSuffix(from) + to
if (key !in all) edit.putValue(key, value)
}
edit.apply()
}
private fun android.content.SharedPreferences.Editor.putValue(name: String, value: Any?) {
when (value) {
is String -> putString(name, value)
is Long -> putLong(name, value)
is Int -> putInt(name, value)
is Boolean -> putBoolean(name, value)
is Float -> putFloat(name, value)
}
}
internal fun movedAddedGame(all: Map<String, *>, from: String, to: String): Map<String, Pair<String, Any?>> =
all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.mapValues { (name, value) ->
val moved = if (value is String && (value == from || value.startsWith("$from/"))) to + value.removePrefix(from) else value
@@ -1009,12 +1118,26 @@ object SessionPrefs {
/** Compositor frame limit that releases buffers to the game unpaced. */
const val FPS_UNPACED = -1
/** The drawer's frame limit slider: its first stop is Off. */
const val FPS_LIMIT_OFF = 15
const val FPS_LIMIT_MAX = 120
/**
* Buffers go back to the game as soon as they are replaced instead of once per refresh;
* Steam's Frame Limit in the Quick Access menu caps games. Mirroring still follows the glasses' refresh rate.
* Steam's Frame Limit in the Quick Access menu caps Steam games, and the drawer's limit caps games
* launched without Steam. Mirroring still follows the glasses' refresh rate.
*/
fun compositorFpsLimit(mirrorRefreshLimit: Int): Int =
if (mirrorRefreshLimit <= 0) FPS_UNPACED else mirrorRefreshLimit
const val PIP_FPS = 30
fun compositorFpsLimit(mirrorRefreshLimit: Int, sessionLimit: Int = 0, pip: Boolean = false): Int {
val limit = when {
mirrorRefreshLimit <= 0 && sessionLimit <= 0 -> FPS_UNPACED
mirrorRefreshLimit <= 0 -> sessionLimit
sessionLimit <= 0 -> mirrorRefreshLimit
else -> minOf(mirrorRefreshLimit, sessionLimit)
}
return if (pip && mirrorRefreshLimit <= 0 && (limit <= 0 || limit > PIP_FPS)) PIP_FPS else limit
}
val upscalerChoices = listOf(
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
@@ -1195,7 +1318,7 @@ object SessionPrefs {
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
else prefMode(mode)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", true)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
@@ -1238,7 +1361,8 @@ object SessionPrefs {
}
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
prefs(context).getString("suspendPolicy.${prefMode(mode)}",
if (mode == SessionService.MODE_STEAM) SUSPEND_NATIVE else SUSPEND_MANUAL)
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
@@ -1264,6 +1388,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
}
fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false)
fun setSlowUsbWarningOff(context: Context) {
prefs(context).edit().putBoolean(SLOW_USB_WARNING_OFF, true).apply()
}
fun slowUsbDismissed(context: Context): Set<String> = prefs(context).getStringSet(SLOW_USB_DISMISSED, null)?.toSet().orEmpty()
fun setSlowUsbDismissed(context: Context, keys: Set<String>) {
prefs(context).edit().putStringSet(SLOW_USB_DISMISSED, keys.toSet()).apply()
}
fun steamDownloadsInBackground(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
@@ -334,6 +334,7 @@ class SessionService : Service() {
return START_NOT_STICKY
}
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
SessionState.fpsLimit = 0
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
activityVisible = true
@@ -498,6 +499,7 @@ class SessionService : Service() {
Log.i(TAG, GpuClockPin.start(this))
Log.i(TAG, GameThreadHints.start(this))
Log.i(TAG, SamsungPerf.start(this))
val size = SessionState.outputSize
val guest = ArrayList<String>()
@@ -535,10 +537,6 @@ class SessionService : Service() {
// gamescope's WSI layer and winex11 reuse their per-present window checks for this long.
guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=100")
guest.add("WINE_OFFSCREEN_CHECK_MS=100")
// gamescope turns buffers around slower than an uncapped game draws; more images keep it drawing.
// The WSI layer goes back to its default count while Steam's Frame Limit holds the game.
guest.add("GAMESCOPE_WSI_MIN_IMAGE_COUNT=8")
guest.add("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
@@ -719,6 +717,7 @@ class SessionService : Service() {
if (!pulse.setMicSuspended(false)) Log.w(TAG, "could not resume microphone source")
},
zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"),
clockControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-clock"),
pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis ->
Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " +
"resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB")
@@ -756,7 +755,7 @@ class SessionService : Service() {
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale(
@@ -820,7 +819,7 @@ class SessionService : Service() {
if (!SessionPrefs.zramClient(this)) guest.add("BL_ZRAM_CLIENT=0")
}
SessionPrefs.vramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_VRAM_MB=$it") }
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
guest.add("AUDIO_DIRECT_DECAY=0")
val extra = extraEnv() + agentEnvironment
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
@@ -843,7 +842,7 @@ class SessionService : Service() {
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
guest.add("BL_MIC_ENABLED=" + if (wantsMic) "1" else "0")
guest.add("BANNER_AUDIO_DIRECT_MIC=" + if (wantsMic) "1" else "0")
guest.add("AUDIO_DIRECT_MIC=" + if (wantsMic) "1" else "0")
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
val relaySocket = File(audioDir, "relay.sock")
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
@@ -871,7 +870,11 @@ class SessionService : Service() {
}
if (wantsDirectAudio) {
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
guest.add("AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
val lowLatency = com.droiddeck.launcher.audio.DirectAudioTiming.lowLatencyOutput()
val timing = com.droiddeck.launcher.audio.DirectAudioTiming.environment(lowLatency)
guest.addAll(timing)
Log.i(TAG, "audio: Android output " + (if (lowLatency) "is low-latency; DirectAudio keeps its defaults" else "is not low-latency; DirectAudio uses " + timing.joinToString(" ")))
}
Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
@@ -880,7 +883,7 @@ class SessionService : Service() {
}
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
FakeInputWriter.prepareRingSlots(fakeInputDir, 5)
val builtinPad = SessionPrefs.builtinPad(this)
val hiddenPad = SessionState.mode == MODE_STEAM && !builtinPad &&
!com.droiddeck.launcher.input.PadBridge.anyControllerConnected()
@@ -1038,7 +1041,7 @@ class SessionService : Service() {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRoots(this)) {
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRootsForXodus(this)) {
if (host.isDirectory && host.canRead()) {
File(LinuxRuntime.rootDir(this), guest.removePrefix("/")).mkdirs()
binds.add(host.path + ":" + guest)
@@ -1337,17 +1340,7 @@ class SessionService : Service() {
/** A Steam game is running: the client runs each one under its reaper, and Proton's script
* waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */
private fun steamGameRunning(): Boolean {
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (text.contains("SteamLaunch AppId=") || text.contains("waitforexitandrun")) return true
}
return false
}
private fun steamGameRunning(): Boolean = SteamGameProcess.running()
private fun resumeSession(panelVisible: Boolean = true) {
if (SessionState.suspended) SessionState.resumeRequested = true
@@ -1819,6 +1812,7 @@ class SessionService : Service() {
SessionState.stopRequested = false
GpuClockPin.stop(this)
GameThreadHints.stop()
SamsungPerf.stop()
suspendOperationPending = false
freezePending = false
launchWatcher?.stopWatching()
@@ -1882,6 +1876,9 @@ class SessionService : Service() {
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (rootIntent?.component?.className in setOf(
com.droiddeck.launcher.ui.DualScreenActivity::class.java.name,
com.droiddeck.launcher.ui.SessionTypingActivity::class.java.name)) return
if (pipTask || xrActivityVisible) {
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
activityVisible = false
@@ -1896,6 +1893,7 @@ class SessionService : Service() {
override fun onDestroy() {
stopSession(0)
GameThreadHints.stop() // also when the session never got as far as running
SamsungPerf.stop()
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
@@ -91,6 +91,8 @@ object SessionState {
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@Volatile var secondScreenDisplay = -1
@Volatile var secondScreenAutoOpened = false
@Volatile var secondScreenProvisional = false
@Volatile
var mode = "steam"
@@ -107,6 +109,10 @@ object SessionState {
@Volatile
var upscaleRatio: Float = 0f
/** Frame limit chosen in the drawer for a game launched without Steam; 0 is off. */
@Volatile
var fpsLimit: Int = 0
@Volatile
var firstFrameSeen = false
@@ -3,6 +3,9 @@ package com.droiddeck.launcher.session
import android.os.Process
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
@@ -19,6 +22,8 @@ internal class SessionSuspendController(
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
private val zramControl: File? = null,
private val clockControl: File? = null,
private val nowNanos: () -> Long = System::nanoTime,
private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit =
{ _, _, _, _, _, _ -> },
) {
@@ -43,6 +48,7 @@ internal class SessionSuspendController(
@Volatile private var closing = false
@Volatile private var cancelled = false
private var frozen = emptyList<ProcessRecord>()
private var frozenSince: Long? = null
fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) {
cancelled = false
@@ -93,6 +99,7 @@ internal class SessionSuspendController(
} catch (_: Exception) {
} finally {
frozen = records.values.toList()
if (complete && frozenSince == null) frozenSince = nowNanos()
if (!complete) {
if (restoreSafely(frozen)) frozen = emptyList()
runAudio(resumeAudio)
@@ -111,6 +118,7 @@ internal class SessionSuspendController(
fun resume(completion: (Boolean) -> Unit) {
submit({
val records = frozen
advanceClock()
runAudio(resumeAudio)
val resumed = restoreSafely(records)
if (resumed) frozen = emptyList()
@@ -121,6 +129,7 @@ internal class SessionSuspendController(
fun closeAndResume(completion: () -> Unit) {
closing = true
submit({
advanceClock()
if (restoreSafely(frozen)) frozen = emptyList()
completion()
executor.shutdown()
@@ -135,6 +144,23 @@ internal class SessionSuspendController(
}
}
private fun advanceClock() {
val since = frozenSince ?: return
frozenSince = null
val control = clockControl?.takeIf { it.isFile && it.length() >= Long.SIZE_BYTES } ?: return
try {
RandomAccessFile(control, "rw").use { file ->
val bytes = ByteArray(Long.SIZE_BYTES)
file.readFully(bytes)
val shift = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).long.coerceAtLeast(0L) +
(nowNanos() - since).coerceAtLeast(0L)
file.seek(0L)
file.write(ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putLong(shift).array())
}
} catch (_: IOException) {
}
}
private fun runAudio(action: () -> Unit) {
try {
action()
@@ -12,13 +12,14 @@ object SettingsReset {
internal val SESSION_KEYS = setOf(
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
"dualScreenMode", "dualScreenLauncher", "dualScreenSessionControls",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"storesInstallLanguage",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "fusionHud", "fusionHudPos", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
"stretch16x9", "picturePosition",
"stretch16x9", "picturePosition", "railLabels",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "performanceBoost", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "storageDiagnostics",
@@ -0,0 +1,18 @@
package com.droiddeck.launcher.session
import java.io.File
object SteamGameProcess {
fun matches(cmdline: ByteArray): Boolean {
val args = String(cmdline).split('\u0000')
for (i in args.indices) {
if (args[i] == "waitforexitandrun") return true
if (args[i] == "SteamLaunch" && args.getOrNull(i + 1)?.startsWith("AppId=") == true) return true
}
return false
}
fun running(proc: File = File("/proc")): Boolean = proc.listFiles()?.any { p ->
p.name.all(Char::isDigit) && runCatching { File(p, "cmdline").readBytes() }.getOrNull()?.let(::matches) == true
} == true
}
@@ -0,0 +1,26 @@
package com.droiddeck.launcher.session
import org.json.JSONObject
import java.io.File
object SteamImportPrompt {
data class Ask(val token: String, val games: List<String>)
private fun folder(root: File) = File(root, "root/.cache/droiddeck")
fun read(root: File): Ask? {
val text = runCatching { File(folder(root), "steam-import.json").readText() }.getOrNull() ?: return null
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
val token = json.optString("token").takeIf { it.isNotEmpty() } ?: return null
val list = json.optJSONArray("games")
val games = if (list == null) emptyList() else (0 until list.length()).mapNotNull { list.optString(it).takeIf(String::isNotBlank) }
return Ask(token, games)
}
fun answer(root: File, token: String, now: Boolean): Boolean = runCatching {
val dir = folder(root).apply { mkdirs() }
val temp = File(dir, "steam-import.answer.tmp")
temp.writeText(JSONObject().put("token", token).put("choice", if (now) "now" else "later").toString())
temp.renameTo(File(dir, "steam-import.answer"))
}.getOrDefault(false)
}
@@ -0,0 +1,122 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.storage.StorageManager
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
object UsbLink {
data class Link(val device: String, val mbps: Double, val name: String, val portMbps: Double?, val plug: String = "")
class SlowDrive(val key: String, val name: String, val mbps: Double, val portMbps: Double?) {
val portLimited get() = portMbps != null && portMbps < FAST_MBPS
}
const val FAST_MBPS = 5000.0
fun markShown(context: Context, drive: SlowDrive) = synchronized(this) {
SessionPrefs.setSlowUsbDismissed(context, SessionPrefs.slowUsbDismissed(context) + drive.key)
}
fun stillDue(context: Context, drive: SlowDrive) =
!SessionPrefs.slowUsbWarningOff(context) && drive.key !in SessionPrefs.slowUsbDismissed(context)
fun slowLibrary(context: Context): SlowDrive? {
if (SessionPrefs.slowUsbWarningOff(context)) return null
val mounts = runCatching { File("/proc/self/mounts").readText() }.getOrNull() ?: return null
val sm = context.getSystemService(StorageManager::class.java)
val boot = runCatching { android.provider.Settings.Global.getInt(context.contentResolver, android.provider.Settings.Global.BOOT_COUNT) }.getOrDefault(0)
val drives = libraries(context).mapNotNull { path ->
val link = forPath(path, mounts) ?: return@mapNotNull null
if (link.mbps >= FAST_MBPS) return@mapNotNull null
val volume = runCatching { sm?.getStorageVolume(File(path))?.getDescription(context) }.getOrNull()
SlowDrive(dismissKey(boot, link), link.name.ifBlank { volume.orEmpty() }.ifBlank { File(path).name }, link.mbps, link.portMbps)
}
return synchronized(this) {
val dismissed = SessionPrefs.slowUsbDismissed(context)
val current = dismissed.intersect(drives.map { it.key }.toSet())
if (current != dismissed) SessionPrefs.setSlowUsbDismissed(context, current)
drives.firstOrNull { it.key !in current }
}
}
internal fun dismissKey(boot: Int, link: Link) = "$boot/${link.plug}/${link.device}@${link.mbps}"
private fun libraries(context: Context): List<String> {
val pref = SessionPrefs.gameStorage(context)
val storage = when (pref) {
SessionPrefs.GAME_STORAGE_OFF -> null
"" -> GameStorage.effective(context)?.path
else -> pref
}
val folders = runCatching { AddedGames.roots(context).map { it.host.absolutePath } }.getOrDefault(emptyList())
return (listOfNotNull(storage) + folders).map(::storagePath).filter { external(it) }.distinct()
}
internal fun storagePath(path: String) = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path
internal fun external(path: String) = path.startsWith("/storage/") && !path.startsWith("/storage/emulated/") && !path.startsWith("/storage/self/")
fun forPath(path: String, mounts: String, sys: File = File("/sys")): Link? {
val device = mountDevice(rawPath(path), mounts) ?: return null
val block = blockDir(device, sys) ?: return null
val usb = usbDevice(block, sys) ?: return null
val mbps = readSpeed(usb) ?: return null
val name = listOf("manufacturer", "product").mapNotNull { read(File(usb, it)) }.joinToString(" ").trim()
val plug = listOf("busnum", "devnum").mapNotNull { read(File(usb, it)) }.joinToString("-")
return Link(device, mbps, name, portSpeed(usb, sys), plug)
}
internal fun rawPath(path: String) = if (external(path)) "/mnt/media_rw/" + path.removePrefix("/storage/") else path
internal fun mountDevice(path: String, mounts: String): String? {
var best: Pair<String, String>? = null
for (line in mounts.lineSequence()) {
val parts = line.split(' ')
if (parts.size < 2) continue
val point = parts[1].replace("\\040", " ")
if (path != point && !path.startsWith("${point.trimEnd('/')}/")) continue
if (best == null || point.length > best.first.length) best = point to parts[0]
}
val source = best?.second ?: return null
Regex(":(\\d+),(\\d+)$").find(source)?.let { return "${it.groupValues[1]}:${it.groupValues[2]}" }
return if (source.startsWith("/dev/block/") || source.startsWith("/dev/sd")) "name:" + source.substringAfterLast('/') else null
}
internal fun blockDir(device: String, sys: File): File? {
if (device.startsWith("name:")) return File(sys, "class/block/${device.removePrefix("name:")}").takeIf { it.exists() }?.canonicalFile
File(sys, "dev/block/$device").takeIf { it.exists() }?.let { return it.canonicalFile }
return File(sys, "class/block").listFiles()?.firstOrNull { read(File(it, "dev")) == device }?.canonicalFile
}
internal fun usbDevice(block: File, sys: File): File? {
val top = sys.canonicalFile
var dir: File? = block
while (dir != null && dir != top) {
if (File(dir, "speed").isFile && File(dir, "idVendor").isFile) return dir
dir = dir.parentFile
}
return null
}
private fun portSpeed(usb: File, sys: File): Double? {
val bus = read(File(usb, "busnum")) ?: return null
val hub = File(sys, "bus/usb/devices/usb$bus").takeIf { it.exists() }?.canonicalFile ?: return null
val controller = hub.parentFile ?: return null
return controller.listFiles { f -> f.name.matches(Regex("usb\\d+")) }?.mapNotNull { readSpeed(it) }?.maxOrNull()
}
private fun readSpeed(dir: File) = read(File(dir, "speed"))?.toDoubleOrNull()
private fun read(file: File) = runCatching { file.readText().trim() }.getOrNull()?.takeIf { it.isNotEmpty() }
fun generation(mbps: Double) = when {
mbps <= 12 -> "USB 1.1"
mbps < FAST_MBPS -> "USB 2.0"
mbps < 10000 -> "USB 3.2 Gen 1"
mbps < 20000 -> "USB 3.2 Gen 2"
else -> "USB 3.2 Gen 2x2"
}
fun megabytesPerSecond(mbps: Double) = (mbps / 8 * 0.7).toInt()
}
@@ -19,7 +19,7 @@ enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM
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 x64: Boolean = false,
val x64: Boolean = false, val prefixDir: File? = null,
) {
val data: Long get() = prefix + shaders + workshop
val total: Long get() = game + data
@@ -175,14 +175,16 @@ object StorageScan {
}
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 compatRoot = File(lib.data, "steamapps/compatdata").takeIf { it.isDirectory } ?: File(lib.root, "steamapps/compatdata")
val prefixDir = File(compatRoot, m.appId.toString()).takeIf { it.isDirectory }
val prefix = prefixDir?.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[lib.x64]?.get(m.appId)
games.add(GameUsage(
m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed,
gameBytes, prefix, shaders, workshop, shaderDir, lib.x64,
gameBytes, prefix, shaders, workshop, shaderDir, lib.x64, prefixDir,
))
}
}
@@ -140,6 +140,13 @@ object StorageState {
FileOps.delete(dir)
})
fun resetPrefix(context: Context, game: GameUsage) = act(context, "prefix-${game.appId}", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == game.appId && it.x64 == game.x64 && it.prefixDir?.path == game.prefixDir?.path }
?.prefixDir ?: error("prefix not found")
check(FileOps.delete(dir)) { "cannot delete $dir" }
true
})
fun removeLeftover(context: Context, l: Leftover) = act(context, "leftover-${l.appId}", { _, fresh ->
val leftover = fresh.leftovers.firstOrNull { it.appId == l.appId && it.prefix?.path == l.prefix?.path && it.shaders?.path == l.shaders?.path }
?: return@act false
@@ -137,10 +137,7 @@ object StoreInstallRoot {
.filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) }
.map { root ->
val base = guestFor(context, root)
val guest = if (used.add(base)) base else {
val suffix = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
"$base-$suffix".also { used.add(it) }
}
val guest = if (used.add(base)) base else "$base-${pathKey(root)}".also { used.add(it) }
root to guest
}
}
@@ -168,15 +165,20 @@ object StoreInstallRoot {
/** Xodus has its own PRoot mount table and cannot see the selected Steam library's SD bind. */
fun externalRootsForXodus(context: Context): List<Pair<File, String>> {
val roots = externalRoots(context).toMutableList()
val roots = externalRoots(context)
val selectedLibrary = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) }
?.takeUnless { isInternal(context, it) }
if (selectedLibrary != null && roots.none { it.first.absolutePath == selectedLibrary.absolutePath }) {
roots += selectedLibrary to guestFor(context, selectedLibrary)
}
return roots
?.takeUnless { isInternal(context, it) } ?: return roots
return withSelectedLibrary(roots, selectedLibrary, guestFor(context, selectedLibrary))
}
internal fun withSelectedLibrary(roots: List<Pair<File, String>>, library: File, base: String): List<Pair<File, String>> {
if (roots.any { it.first.absolutePath == library.absolutePath }) return roots
val guest = if (roots.none { it.second == base }) base else "$base-${pathKey(library)}"
return roots + (library to guest)
}
private fun pathKey(root: File): String = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
fun externalBindSpecs(context: Context): List<String> = externalBindSpecs(externalRoots(context), LinuxRuntime.rootDir(context))
fun externalBindSpecsForXodus(context: Context): List<String> =
@@ -192,7 +194,7 @@ object StoreInstallRoot {
private fun guestFor(context: Context, root: File): String {
val sm = context.getSystemService(StorageManager::class.java)
val uuid = runCatching { sm?.getStorageVolume(root)?.uuid }.getOrNull()?.takeIf { it.isNotBlank() }?.replace(Regex("[^A-Za-z0-9-]"), "")
val key = uuid ?: "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
val key = uuid ?: pathKey(root)
return "$GUEST_EXTERNAL/$key"
}
@@ -254,6 +256,7 @@ object StoreInstallRoot {
val sidecar = StoreGameSidecar.read(folder)
when {
sidecar == null -> Unfinished(store, null, folder)
store == Store.GOG && sidecar.state == StoreGameSidecar.STATE_UPDATING -> null
!sidecar.isInstalled -> Unfinished(store, sidecar.id, folder)
else -> null
}
@@ -81,7 +81,7 @@ object StoresState {
runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(app) }
val found = Store.entries.mapNotNull { store -> StoreAccounts.displayName(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) }
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled || it.store == Store.GOG && it.state == StoreGameSidecar.STATE_UPDATING }?.let { InstalledStoreGame(it, folder) }
} + runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetBackend.installedGames(app) }.getOrDefault(emptyList())
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
@@ -95,6 +95,7 @@ object StoresState {
engine = version
accountsLoaded = true
}
com.droiddeck.launcher.stores.gog.GogMaintenance.checkInstalled(app, onDisk)
}
}
}
@@ -31,6 +31,9 @@ data class DownloadEntry(
val stageItemsTotal: Int = 0,
val diskBytes: Long = 0L,
val stagesPassed: Int = 0,
val interruptible: Boolean = true,
val reusedBytes: Long = 0L,
val appliedBytes: Long = 0L,
) {
fun passed(s: DownloadStage): Boolean = state == DownloadState.INSTALLED || (stagesPassed and (1 shl s.ordinal)) != 0
Loaded 100 of 633 files, more files were not shown because too many files have changed in this diff. Show more