Compare commits

..
Author SHA1 Message Date
xXJsonDeruloXx c26fa0981c wip: multi-controller session and pad bridge changes 2026-10-14 12:16:24 -04:00
Kurt Himebauch ec24b3a472 feat: the built-in pad can be a DualShock 4 or DualSense, its touchpad fed by the trackpads (#559) 2026-10-11 04:21:31 +00:00
Kurt Himebauch 6455b1dade feat: the built-in pad's controller type switches from the session menu while Steam runs (#558) 2026-10-11 00:12:53 -04:00
Kurt Himebauch 542ad3e060 feat: the built-in pad can be a Steam Controller to Steam (#545) 2026-10-11 03:24:22 +00:00
Kurt Himebauch 910fb47692 fix: the Steam Controller's hidraw node reaches the input shim again (#557) 2026-10-10 23:20:09 -04:00
Kurt Himebauch 3dd14dd625 refactor: Steam always runs in Deck mode on the Deck beta branch (#546) 2026-10-10 22:15:42 -04:00
The412Banner d1b0ac4df1 Windows components: picked automatically by each game's source (#554)
Each game's Windows components now come from the list its own source
trusts, and the ones that list names turn on by themselves. The user's own
on/off switches always win; each automatic row says why it is on.

- Steam games: the Steamworks Shared redistributables Steam installs with
  the game (appmanifest SharedDepots, else the client's appinfo.vdf).
  Steam keeps its install scripts marked as already run.
- GOG: the build manifest's dependencies. Epic: the manifest's
  prerequisite (Unreal's maps to VC++ and DirectX). Amazon: fuel.json
  PostInstall. Older GOG/Epic installs get their list fetched once with the
  saved sign-in; game files are never downloaded again.
- Custom games (the + button, Add all, migrated folders): one shared Steam
  match per game, also used for artwork. An appid from the game's files
  turns components on; a name-only match stays Recommended; no match falls
  back to the folder scan.
- Steam app info for apps the client never saw comes from an anonymous
  Steam PICS lookup (CM websocket, anonymous logon, product info), cached
  per appid; the user's Steam sign-in is never used.
- Missing components download in the background on one low-priority
  worker that pauses during sessions and waits on battery saver, heat or a
  metered network unless the user asked. A game Steam installs during a
  session is readied right away so it can be played without ending the
  session; the launch waits up to 10 s only for a component still on its
  way.
- Page: "On for this game" with reasons, a quiet "Checking what this game
  needs..." only for games never worked out, rows that ease in, pieces that
  need a download greyed out while offline. The intro names no other app.
- The folder scan skips Unity's *_Data/Managed (Mono, not .NET Framework).
2026-10-10 21:15:56 -04:00
MaxsTechReview 0e225d0c6e FEX: preload the fault report only where x86 loaders look (#551)
Preload the FEX fault report only where x86 loaders look

droiddeck-fex put the x86 libfaultreport.so in LD_PRELOAD, which FEX's own
arm64 loader reads too - and FEXServer's, and every child FEX's - so each
x86 start printed "ERROR: ld.so: object '.../libfaultreport.so' from
LD_PRELOAD cannot be preloaded", and an i386 child added "wrong ELF class".
It is now listed in the x86 runtime's own /etc/ld.so.preload, which only
the loaders inside FEX read, as /usr/$LIB/droiddeck/libfaultreport.so.

faultreport.c builds for i386 too, so 32-bit programs get the report as
well instead of an error.
2026-10-10 19:12:51 -04:00
MaxsTechReview d9cd0d2cc8 Desktop: run programs, scripts and AppImages; Switch to Desktop without Deck mode (#549)
* Run programs, scripts and AppImages from the desktop

Windows programs open with the Proton Steam has chosen, and a Steam game's own
launch program is handed to Steam. x86-64 Linux programs and game scripts run
under FEX, and an exec the kernel refuses goes to droiddeck-open through
libblsession's binfmt.c. Programs on noexec shared storage pass KIO's checks; a
folder of Linux programs there, and sharun AppImages, are added by the app once
and run from the copy. Executable scripts run without KIO's prompt, mpv plays
on the software desktop, FEXServer's socket moves under XDG_RUNTIME_DIR, and
Proton runtimes no longer show up as games.

* Switch to Desktop from Big Picture without Deck mode

Big Picture's Switch to Desktop asks the client's SteamOS manager, which the
client only has when started as SteamOS (Deck mode). Without it the request
failed and the screen stayed on "Switching to Desktop..." for good. The live
compat helper now sees the request on its way to the client and hands it to
steamos-session-select, which opens the KDE desktop as Deck mode already did.

* Run x86 programs from shared storage where they lie

FEX asks for the guest's PROT_EXEC when it maps a program or library, which
the kernel refuses on noexec shared storage; FEX never runs those pages
itself, so libblsession maps them without it in FEX's processes. An x86
game, its libraries and its start script now run in place instead of being
copied in; only native code on noexec storage is still copied.

A Steam library's games are never copied, and a Linux game's launch program
opened from the desktop goes to Steam like a Windows one.
2026-10-10 19:12:20 -04:00
MaxsTechReview eadebc46d5 Performance: idle front end, proot stops, launch-time passes and log collection (#529)
* Front end: draw the capsule wall without clipping it every frame

The tilted wall clipped each capsule to a rounded rect and recomposed its columns on every drift
step, and a rotated rounded clip is rasterised in software on the render threads: the idle main
page held about 1.7 cores. The art is now loaded at the capsule's size with its corners already
rounded, each column draws its capsules itself in one layer moved by the drift, and the drift
advances at most every 15 ms. The idle page takes about a third of a core.

* libfakeinput: let calls on descriptors it does not serve skip its lock

Every read, write, ioctl, close and poll of every process in a Proton session went through the
controller lock and the three map lookups, Wine's wineserver pipes and the game's files alike.
A bitmap of the descriptors the maps hold, set before one is handed out and cleared when it is
closed, lets everything else go straight to the kernel.

* Pad motion: ask the display for its rotation four times a second

The rotation was looked up for every sensor reading, every 2 ms, and cost more than the rest of
the reading; it is now refreshed every 250 ms, and the readings no longer allocate pairs.

* Session: keep the library passes out of a game's launch

The redist seeder stamped each prefix with the checksum of the whole set of names, which differs
between the session's pass and the game launch's (the game's own names are added), so every
launch re-appended the markers to every prefix in the library while the game loaded. Each
prefix's marker now lists the names it was given and only the missing ones are added.

The beat's seed and compatibility passes, and the Lossless Scaling ownership probe (which loads
steamclient.so), also wait while a wineserver runs: a launch rewrites the manifest and made them
due at exactly that moment. The game's own launch seeds its prefix regardless.

* Agent: keep the focus watcher's X connection open while nothing changes

The connect timeout stayed on the socket, so with no property change the read timed out, and the
watcher dropped the connection, reconnected and took a fresh snapshot every few seconds.

* Game environment: start Cyberpunk 2077 without REDlauncher

The prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the game half a
minute later and then stays up beside it, well over a core busy for as long as the game runs; its
own --launcher-skip started the game twice. The game's executable is started directly instead.

* netif: answer Wine's network driver from a reading of the link

Mortal Kombat X has Wine's network driver enumerate every interface, address and route table
hundreds of times a second. Each enumeration read /etc/droiddeck-net several times, asked the
kernel for its interface dump again (a netlink socket bound under proot, refused each time) and
looked the real functions up with dlsym. The link file is now read at most once a second, a
refused dump is not asked for again within that second, and the real functions are looked up
once.

* Fast path: create memfds without a stop in proot

proot looks at a memfd's name only to refuse the few that are then executed or locked; every other
memfd_create is now issued from the trampoline. The session's /proc/net and status copies made
one per read, a stop each. Without memfd in the kernel the call still goes to proot's ashmem.

* Logs: run each redaction rule only on a line that could match it

A session's end copies some 70 MB of the Steam client's logs and puts every line through all
twenty-odd rules, most of a minute of a core on the phone after every session. Each rule now runs
only on a line holding what it cannot match without: its keyword, the five separators of a MAC,
the four dashes of a GUID, a long enough hex or token run, the learned account or address text.
Lines with non-ASCII text still meet every case-insensitive rule. Over 895,000 lines of real
client logs, with the device's accounts and addresses learned, the output is identical and the
pass takes a twentieth of the time.
2026-10-10 19:00:33 -04:00
SG3xHERO 96d7c66535 Add Steam Controller support over Bluetooth LE (#404) 2026-10-10 20:53:07 +00:00
The412Banner 90690d1c5c Read the package catalogs from Droid-Deck/DroidDeck-Components (#544)
The Windows components catalog, the desktop and emulator packages, the Linux runtime and the
Steam seed now live on the organisation's components repository, mirrored and built there by
CI; the app reads the catalogs from its main and the files from its releases. The personal
winlator-contents mirror keeps serving older app versions and Bannerlator.
2026-10-10 16:52:33 -04:00
The412Banner abbcb8e3ad Games: old Games folders become entries, not Steam libraries (#542)
Games folders saved before the settings section went away were still
registered as Steam libraries on Automatic, with no way left to remove
them. Once, at app start and before any scan, each game such a folder
listed becomes an entry of its own, keeping everything keyed by its folder
(chosen exe, shortcut appid, name, Start in, launch options, art,
removal); store installs and steamapps are skipped. The folders stay bound
at their old guest paths, so no shortcut changes, and the saved roots are
cleared. A Games folder stays a library only when Steam installed a game
into it; the others leave the library list, and the runtime forgets them.
2026-10-10 16:19:17 -04:00
David Kubeša fe60b3c279 Add global manual landscape orientation setting (#441) 2026-10-10 16:07:12 -04:00
Kurt Himebauch c7455b74ff Merge rules (#540) 2026-10-10 19:13:01 +00:00
Ashish Ajin c875ceda4d Hide the touchpad cursor when switching to direct touch (#424) 2026-10-10 15:02:38 -04:00
The412Banner 666abd134f Windows components: a recommended component is listed once, not again under All (#537)
The full list left out only the 'installed for this game' rows, never the recommendations, so
every recommended component appeared twice (DirectX 9 on Final Fantasy Resonance, both rows
switched on).
2026-10-10 14:54:28 -04:00
Muaz Kadan c115916962 Read the phone's motion sensors only while Steam asks for them (#534)
perf: only read the phone's motion sensors when Steam wants them

The gyro and accelerometer ran at 4 ms for the whole session, even in games without gyro, costing about 6% of a core. Steam already tells the Deck pad when it wants motion (setting 48), so libfakeinput now passes that on and PadMotion follows it, with a short delay to ride out Steam's brief toggles.
2026-10-10 14:44:05 -04:00
0579e688ba fix: preserve keyboard input on composite devices (#435)
* fix: preserve keyboard input on composite devices

* Fix composite input merge noteDevice call

---------

Co-authored-by: Jammo <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-10 14:40:53 -04:00
Muaz Kadan bc50de3b55 Fix CPU temperature showing 0°C in the performance overlay (#516)
Since the proot speed-up in #494, the overlay couldn't find the CPU sensor and showed 0°C. It shows the real temperature again.
2026-10-10 14:39:30 -04:00
ArthurandJames Yeung 6489232598 Fix NativeAOT GC region range defaults (#385)
Use the bare hex value accepted by older NativeAOT runtimes and CoreCLR. Cover the default and shared and per-game overrides.

Co-authored-by: James Yeung <[email protected]>
2026-10-10 14:38:47 -04:00
0x142857andxXJsonDeruloXx c23c436502 Controller: optional Select button that opens the Steam menu (#384)
* Controller: optional Select button that opens the Steam menu

* Controller: translate the Select-opens-Steam-menu option

* Controller: clearer wording for the Select-opens-Steam-menu option

* docs: evidence for the Select-opens-Steam-menu option

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-10 14:38:07 -04:00
abduznik fad52ec125 Terminal: release createSubprocess's JNI local references, and the cwd chars against cwd (#527)
Fix JNI local reference leak in createSubprocess

The argv and envp loops took one local reference per element from
GetObjectArrayElement and never released it, so the references piled up
until the native call returned. ART aborts the process at 512 local
references ("local reference table overflow"), and both loops share the
table, so a long argument list plus a large environment could hit it.

Delete each element's local reference once its chars are copied and
released, after ReleaseStringUTFChars since the chars depend on the string.

Also release the cwd chars against cwd rather than cmd; the call passed
the wrong jstring.

Adds a host-side test that runs termux.c against a mock JNIEnv that counts
local references and checks release pairing. It needs a jni.h (JNI_INCLUDE,
JAVA_HOME or the NDK) and skips without one.

Fixes #507
2026-10-10 14:29:51 -04:00
Nicolás Gómez Solano d22e458d64 fix(audio): avoid InputStream.readAllBytes() for Android 12 compatibility (#531) 2026-10-10 14:29:46 -04:00
The412Banner 1b7cb4179d Windows components: leave out WebView2, which only Microsoft's installer can lay out (#532)
Its files sit in a container no extractor opens and may not be hosted, so a recording cannot
source them; installing it means running the installer in the prefix, which no runtime here
does. With it gone the waiting section is empty.
2026-10-10 14:03:04 -04:00
MaxsTechReviewandxXJsonDeruloXx c1c22dfd37 Launch the executable a Store package declares (#400)
Store and GDK folders name their game in MicrosoftGame.config or
appxmanifest.xml. Picking the top-level Unreal bootstrap instead failed
with "CreateProcess() returned 2" when the Store had renamed the
shipping exe.

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-10 13:46:03 -04:00
kmnkv1 0043cefab5 Rumble: don't restart the vibrator on every force-feedback packet (#514)
fix: stop restarting the vibrator on every force-feedback packet
2026-10-10 13:34:19 -04:00
kaosjammoandJammo 41f68ef1a1 Fix launcher fullscreen around display cutouts (#432)
Co-authored-by: Jammo <[email protected]>
2026-10-10 13:33:59 -04:00
The412Banner 9743ac3abc Windows components: leave out the OpenAL setup program, a twin of the DLL copy that installs here (#528) 2026-10-10 13:32:12 -04:00
SG3xHERO ca6cfcd193 Unfolded deck controls (#407) 2026-10-10 17:24:23 +00:00
Willie Yao d41424170a Controller editor: snap controls into line while dragging (#490) 2026-10-10 15:11:50 +00:00
kmnkv1 f4678935c4 Odin 3: skip the low-latency Wi-Fi lock, whose WLAN firmware hang reboots the device (#512) 2026-10-10 11:06:29 -04:00
Kurt Himebauch 416eb41d8d feat(desktop): add desktop uninstall and legacy LXQt cleanup (#493) 2026-10-10 10:59:32 -04:00
Kurt Himebauch 6e0d456dd7 fix(amazon): sync library with nile's headers and registered serial (#500) 2026-10-10 10:58:18 -04:00
The412Banner 8ac555664a Windows components: Access 2000 runtime installs here (an MSI inside a 7-Zip-openable wrapper) (#505)
art2kmin.exe is Setup.exe + Data1.msi in a wrapper 7-Zip opens, so the MSI engine lays it out
like any other package; it was on the setup-program list by assumption.
2026-10-10 01:31:23 -04:00
The412Banner 4bf4e53ecf Build: verify the runtime inputs only after the session scripts are staged (#504)
With parallel task execution, verifyRuntimeInputs and stageSessionScripts could run at the
same time; main's build at 3d50e6b1 hashed droiddeck-netmanager mid-copy and failed with
'Missing or changed runtime input'. The check now runs after the staging tasks.
2026-10-10 00:38:23 -04:00
The412Banner 3d50e6b1ae Windows components: hide Vulkan RT and the IE8 update; honest captions for the waiting section (#502)
* Windows components: hide Vulkan RT, whose loader every Proton here ships

vulkan-1.dll is in lib/wine/x86_64-windows and i386-windows of Proton Experimental (ARM64),
GE-Proton and proton-cachyos alike, so the catalog's VulkanRT entry would only shadow it. It
joins the list the page leaves out.

* Windows components: the waiting section says what is really missing (a recording), not 'coming next'

* Windows components: leave out the IE8 security update, which patches a browser no prefix has

* Windows components: the waiting caption without an apostrophe the resource compiler rejects
2026-10-10 00:24:14 -04:00
The412Banner 846b20f78a CI: re-pin the Debian image and stop the publish workflow replacing it (#501)
The publish workflow's push trigger fired again on the branch's rebase and overwrote the
release asset with a freshly compressed copy, so main's first build failed the checksum and
fell back to the registry pull. The env file now pins the asset as published; the workflow is
dispatch-only, defaults to a new tag, and refuses to replace an existing asset unless asked.
2026-10-09 23:59:58 -04:00
The412Banner a229d196bb CI: host the Debian image the preloads build in on a release of this repo (#497)
* CI: host the Debian image the preloads build in on a release of this repo

The preloads step pulled debian:bullseye from a public mirror on every build whose preload
cache missed; the mirrors rate-limit the shared runner addresses and failed two builds in an
hour tonight. Debian 11 is frozen upstream, so the image is published once (workflow
ci-debian-image.yml, release ci-debian-bullseye-r1, pinned in tools/linuxfs/debian-image.env)
and the build downloads that file; only if that fails does it pull, with retries.

* CI: pin the published Debian image (ci-debian-bullseye-r1, 34 MB)

* CI: a new Debian image rebuilds the preloads (env file joins the cache key)
2026-10-09 23:21:11 -04:00
The412Banner 61bfc04704 Windows components: hide the CJK fonts and PowerShell, which every Proton here ships (#496)
Proton Experimental (ARM64), GE-Proton and proton-cachyos all carry Malgun, MS Gothic, YaHei
and SimSun in share/fonts and Wine's PowerShell wrapper, so the catalog's cjkfonts and
powershell entries would only shadow what is already there. They join gecko and mono in the
list the page leaves out.
2026-10-09 23:13:53 -04:00
The412Banner 9b90386e73 Windows components: install from a recording of the installer's result (#495)
* Windows components: install from a recording of the installer's result

A catalog entry may name a 'snapshot': what its own setup program leaves in a prefix,
recorded once in Wine by winlator-contents' tools/snapshot/record.py (the files it places and
the registry values it writes, the self-registering DLLs' CLSID entries included). With one,
the setup program never runs here: the files come from the archive beside the recording, or
out of the vendor's installer by the paths the recording maps (small generated ones inline),
and the registry values go to registry.json for droiddeck-wincomponents to write at launch.
The steps a recording makes redundant are skipped; DLL overrides still apply. A component
with a recording counts as installable; its prerequisites must still be.

* droiddeck-msi-install --unpack: open archives nested inside an installer

QuickTime's cabinets and an NSIS pack's payloads sit inside the unpacked installer; they are
opened in place into '<name>.nested' folders, two levels deep, the same layout the component
recordings map their file sources to, so a recording's paths resolve on the device (QuickTime:
593 of 593).
2026-10-09 22:56:18 -04:00
Kurt Himebauchandmaxjivi05 ddc4c18faf Steam's touch controller as on-screen controls (beta) (#431)
* feat: Steam's touch controller as on-screen controls (beta)

* fix: Steam touch controls appear once the session makes the device; tidier default layout

* fix: Steam touch layout decoding, saved layouts and new sessions

* docs: Steam's touch controls

* feat: Steam's touch icons, labels and colours on the touch controls; pick and save them like Steam

* feat: Steam touch icon picker as in-session panels; layout colour; docs

* Give each physical controller its own player slot

Every controller fed the one exported pad, so two pads played as one.
The first controller keeps slot 0 (the Deck in a Steam session); further
ones get slots 1-3 as separate Xbox 360 pads, created on first input and
removed on disconnect. In Deck mode only the Steam client sees those
pads and hands them to games through Steam Input. Rumble follows the
slot it names.

* feat: Steam Link's touch menu, per-game input modes, action set editing, add/copy/paste, oval controls, paste control

* feat: Steam touch controls drawn as Steam Link draws them; Steam Link's touch menu and layout tray

* feat: Steam touch controls in DroidDeck's style, D-mark guide button, shake to hide

* refactor: Steam touch controls become an On-screen controls choice instead of a second dropdown

---------

Co-authored-by: maxjivi05 <[email protected]>
2026-10-09 21:12:46 -04:00
MaxsTechReview fb8361b197 Expanded Fastpaths for proot and increased performance up to 57% (#494)
* perf: fast path resolves links, /proc and glue as proot does, and answers uname, isatty and passwd

The fast path handed back to proot whatever crossed a symlink, /proc or a directory proot
only shows as glue, which is most of what Steam and Wine ask. It now walks a path the way
proot's canonicalize() does, so those calls stay in-process:

- links are followed in guest terms and their text detranslated as detranslate_path() does,
  including readlink into a buffer the kernel cut short and /proc's exe, cwd and fd links;
- a binding proot dropped at start is dropped here too, glue above a binding is ENOENT, and a
  last component proot cannot stat is left to the kernel;
- pipe and socket dirfds are ENOTDIR, rmdir("dir/") removes dir, and "." and ".." go to proot;
- a blocking accept() with a connection already waiting no longer goes to proot.

Also answered: uname() and gethostname() (proot's utsname never changes), isatty() (glibc
2.43 asks with TCGETS2, which proot rewrites) and getpwnam/getpwuid when nsswitch.conf
sends passwd to files first and the entry is there (Xwayland asks for every client).

equiv.py, equiv2.py and equiv3.py match line for line with and without the fast path
(222, 507 and 924 lines). Per call on the RedMagic: realpath 240 us -> 1.0 us,
realpath /proc/self/fd/N 408 -> 9.9, getpwuid 41.5 -> 4.6, uname 28 -> 0.006.

* perf: LD_AUDIT module skips library lookups proot would fail

ld.so looks each library up along the game's search path, and Proton puts the game's
directory, Steam's runtime and video directories and its own library directories ahead of
/usr/lib: 72% of ld.so's opens in an Among Us launch failed, each a round trip to proot.

libblaudit.so answers la_objsearch() for a candidate proot would certainly fail with ENOENT
(its directory walked as canonicalize() walks it, the name or a directory on the way missing).
It has no libc and maps the fast path's trampoline itself. Each lookup's first candidate is
still opened, since a skipped one fails with ld.so's errno as it stands, and the default
directories are never skipped, so dlerror() reads the same. droiddeck-proton and the extra
wrapper add it to LD_AUDIT when the fast path is on.

* perf: droiddeck-proton checks a tool's components with one sha256sum

Each program the launcher starts pays a dozen traced library lookups under the game's search
path before it does anything. One sha256sum over every listed file replaces one per file;
the check itself is unchanged.

* perf: build a locale archive for C.UTF-8

Every program loads the locale LANG names before main(). glibc ships C.UTF-8 as a directory
it looks for once per category under two spellings, before reading gconv's module lists:
about 30 opens, each a stop in proot, in every process of the session. droiddeck-session
builds /usr/lib/locale/locale-archive (and gconv's cache) once per glibc package, so that is
one open.

localedef names a finished archive by hard link, which Android refuses; with
DROIDDECK_LINK_RENAME=1 libblsession turns a refused link into a rename that will not replace
an existing name.

* perf: library beat starts its helpers only when what they read changed

Every 15 s the session ran droiddeck-seed-redists (some forty programs under proot) and, when
a library directory had changed, steam-compatibility: a burst of thousands of proot stops each
quarter minute, game loads included. The seeder now runs when a library changed or a prefix
(or Proton's template for them) is unmarked, the compat pass when a manifest, depot, tool or
config.vdf changed, both checked from bash without starting anything, and each every five
minutes regardless. The seeder reads its stamp and the time without a subshell each.

* perf: fakeinput opens pad.log on its first line

Every process the library is loaded in opened the session's pad.log at start, a stop in proot
on shared storage, and most never write to it. The file is now opened when there is a line to
write.

* docs: the fast path as it ships, the audit module and measured launch times

* fix: a game the app launches after an idle spell comes to the front

The Steam client brings a game it launched forward only while it counts someone as present,
which it reads from gamescope's input counter. A game the app launched after four or more
minutes without a touch started, mapped its window, and stayed behind the library page until
the next input (device: Palworld, Stardew Valley, Fallout: New Vegas, Mortal Kombat X; the same
on main). The session now watches a requested launch, and when the game's window is focusable
but the interface keeps focus for two polls, counts one input on gamescope's counter, as a key
would. Nothing changes for a launch the client already brings forward.
2026-10-09 20:55:18 -04:00
The412Banner a5fd64f9f2 Games: revamped add games feature (#492)
Adding and managing your own (non-Steam) games moves out of the Steam
settings into the Games tab, and works like Steam's own non-Steam
Properties.

- A + beside "Games N" opens a compact in-app picker: the storage first,
  then that storage's root, every folder, .exe files only. Picking an exe
  adds the game with its name, art, Custom badge and Steam shortcut.
- "Add all games in this folder" adds every game folder inside at once.
  A summary opens immediately and fills in as each game is found, with a
  "?" where the exe was a close call and a red X to remove a game.
- An edit button on each Custom game opens its editor: Name, Target
  (ranked exes, Other exe…), Start in, Launch options, the four pieces of
  art with thumbnails, and Remove. Edits go through the same Steam
  shortcut, so the appid never changes and nothing duplicates.
- Art chooser per piece: images in the game folder, Steam's official art,
  SteamGridDB alternatives, your own image, or back to automatic.
- Finding the exe: up to five folders deep, every exe scored (name match,
  Unreal -Shipping builds, bin folders and size up; launchers, crash
  reporters, installers, redistributables and depth down).
- Real titles from the game's files (Steam manifest, GOG info, the exe's
  version info), keeping the folder's spacing when both spell the same
  title, underscores read as spaces.
- Art: the exact Steam appid when the game names one, strict name
  matches otherwise, Steam's hashed asset paths for newer games,
  SteamGridDB as a fallback (built-in key from the SGDB_API_KEY secret,
  optional own key in Setup, sealed with the Keystore), and a cover made
  from the exe's icon when nothing else has one.
- Removing a game is instant and keeps its files; adding it again brings
  it back. Steam's shortcut list is rewritten once, in the background.
- The Games section is gone from Steam settings; folders added before
  keep listing their games. Artwork switch and SteamGridDB key are in
  Setup.
2026-10-09 19:50:03 -04:00
Kurt Himebauch e0a7ce4f88 fix(desktop): address KDE feedback and keep local builds in sync with CI (#488) 2026-10-09 21:35:37 +00:00
Kurt Himebauch b213ae77b4 feat(stores): bring the launcher's motion to the stores pages (#482)
* refactor(ui): share End session's step-out as StepOut

* feat(ui): let PageFlood carry a tile's art

* feat(stores): bring the launcher's motion to the stores pages

* fix(stores): lay out the pane's rows in a column

* fix(stores): a game page no longer leaves the pad's focus on a dead card and crashes the next press

* perf(stores): GOG remembers its non-games and the library cache loads off the main thread

* fix(ui): a step-out's backdrop never takes the pad's focus

* feat(stores): modes step out of the store chip, each store in its own colour, search in the header

* feat(stores): sign-in pages in the Steam tab's style and an are-you-sure for sign out

* style(stores): drop the sign-in page's store eyebrow, which the title already names

* style(stores): the sign-in page is the store's name and Sign in

* fix(ui): the focus ring follows a control until it stops and meets one still rising into place

* feat(stores): the store a pad's focus is on shows without A, and a press never lands on where it sent focus

* perf(stores): keep chip motion responsive while libraries change
2026-10-09 14:55:03 -04:00
224 changed files with 23927 additions and 1560 deletions

No files matched your search

+23 -2
View File
@@ -24,11 +24,30 @@ permissions:
contents: write
jobs:
kwin:
runs-on: ubuntu-24.04-arm
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Build KWin with nested desktop scaling
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv patch binutils >/dev/null
bash tools/desktop-kde/build-kwin.sh out/kwin
- uses: actions/upload-artifact@v4
with:
name: desktop-kde-kwin
retention-days: 7
path: out/kwin/
build:
needs: kwin
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: desktop-kde-kwin
path: out/kwin
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
@@ -50,7 +69,8 @@ jobs:
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
tools/desktop-kde/build.sh out
KDE_KWIN_OVERRIDE="$PWD/out/kwin" 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)"
# The package itself too, briefly: enough to try a build on a device before publishing it.
- uses: actions/upload-artifact@v4
@@ -61,6 +81,7 @@ jobs:
path: |
out/desktop-kde.tar.zst
out/desktop-kde.packages.txt
out/desktop-kde.kwin.txt
out/desktop-kde.sha256
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
@@ -76,5 +97,5 @@ jobs:
fi
gh release create "$TAG" --latest=false --prerelease=false --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.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
gh release edit "$TAG" --latest=false --prerelease=false
+54 -76
View File
@@ -44,6 +44,19 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Check co-authors
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
messages=$(git log --format=%B "$base..HEAD")
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
exit 1
fi
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
@@ -173,7 +186,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -195,6 +208,9 @@ jobs:
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \
-fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c
aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so"
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
@@ -272,11 +288,38 @@ jobs:
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
ls -lR "$D"
# The Debian 11 image the preloads are compiled in comes from a release on this repo
# (published once by ci-debian-image.yml, pinned in tools/linuxfs/debian-image.env): the
# public registry mirrors rate-limit the shared runner addresses and failed builds on a
# busy evening. Only if that download fails is the image pulled, with retries.
- name: Fetch the Debian image the preloads build in
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
. tools/linuxfs/debian-image.env
image=public.ecr.aws/docker/library/debian:bullseye
if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \
&& { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then
zstd -dc /tmp/debian-image.tar.zst | docker load
rm -f /tmp/debian-image.tar.zst
else
echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead"
for attempt in 1 2 3 4 5; do
docker pull "$image" && break
[ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; }
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
sleep $((attempt * 30))
done
fi
docker image ls "$image"
- name: Build the x86 preloads FEX guests load (glibc 2.31)
if: steps.preloads.outputs.cache-hit != 'true'
run: |
set -euo pipefail
docker run --rm -v "$PWD:/src" -w /src debian:bullseye bash -c '
docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
set -euo pipefail
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
apt-get -o Acquire::Check-Valid-Until=false update -qq
@@ -340,8 +383,9 @@ jobs:
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
mkdir -p app/build/prepared-assets
mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
- name: Cache proot
id: proot
@@ -361,42 +405,7 @@ jobs:
- name: Check the preloads
run: |
set -euo pipefail
D=app/src/main/assets/linuxfs
NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
for bad in libstdc++.so.6 libgcc_s.so.1; do
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
echo "::error::libfakeinput.so links $bad - the C++ runtime must stay static"; exit 1
fi
done
syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; }
FAKE=$(syms "$D/libfakeinput.so")
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
done
# The static C++ runtime stays private: every session process preloads this library,
# and an exported personality routine takes over other programs' unwinding (shadPS4
# aborted in pthread_exit).
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
fi
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
if grep -qx drmIoctl <<<"$SESSION"; then
echo "::error::libblsession.so exports drmIoctl"; exit 1
fi
SSBS=$(syms "$D/libssbs.so")
for sym in sigaction signal bsd_signal sysv_signal; do
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/droiddeck-session"
test -f "$D/usr/local/bin/droiddeck-proton-extra"
test -f "$D/usr/local/bin/droiddeck-msi-install"
for f in msiinfo cabextract 7z 7z.so libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
done
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
@@ -445,10 +454,13 @@ jobs:
id: build
env:
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
python3 tools/release/runtime_inputs.py record
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
python3 tools/release/runtime_inputs.py apk app/build/outputs/apk/release/app-release.apk
- name: Check the store engines' library
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
@@ -457,21 +469,8 @@ jobs:
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
NM="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
SO=app/build/rustJniLibs/arm64-v8a/libdroiddeckstores.so
test -s "$SO"
ls -la "$SO"
unzip -l app/build/outputs/apk/release/app-release.apk | grep -q 'lib/arm64-v8a/libdroiddeckstores.so' \
|| { echo "::error::libdroiddeckstores.so is not in the apk"; exit 1; }
for sym in \
Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion \
Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart \
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart \
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble \
Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart; do
"$NM" -D --defined-only "$SO" | grep -q " ${sym}\$" \
|| { echo "::error::libdroiddeckstores.so does not export $sym"; exit 1; }
done
python3 tools/release/check_android_libraries.py app/build/outputs/apk/release/app-release.apk \
--readelf "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
git diff --stat -- app/src/main/rust/stores/Cargo.lock
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; }
@@ -502,27 +501,6 @@ jobs:
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
- name: Check the native dependency closure
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
WORK=$(mktemp -d)
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
cd "$WORK/lib/arm64-v8a"
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
fail=0
for so in *.so; do
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
[ -f "$need" ] && continue
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
fail=1
done
done
exit $fail
- name: Sign with the testkey (v1 + v2 + v3)
run: |
set -euo pipefail
+55
View File
@@ -0,0 +1,55 @@
name: Publish the CI Debian image
# Pulls the Debian 11 image the x86 preloads build in, once, and publishes it as a release asset
# on this repo so build.yml never depends on a registry mirror's rate limit. Run it again (with a
# new tag) only to refresh the image; tools/linuxfs/debian-image.env names the one builds use.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish to (a new -rN tag for a refresh; builds pin the one in tools/linuxfs/debian-image.env)'
required: true
default: 'ci-debian-bullseye-r2'
replace:
description: 'Overwrite the asset if the release already has one (changes its checksum: re-pin it in debian-image.env)'
type: boolean
default: false
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Pull, save and publish
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
REPLACE: ${{ inputs.replace }}
run: |
set -euo pipefail
image=public.ecr.aws/docker/library/debian:bullseye
# A published image is pinned by checksum in the build; never replace one by accident.
if [ "$REPLACE" != true ] && gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' 2>/dev/null | grep -qx debian-bullseye.tar.zst; then
echo "::error::$TAG already carries debian-bullseye.tar.zst; publish to a new tag, or set replace and re-pin tools/linuxfs/debian-image.env"
exit 1
fi
for attempt in 1 2 3 4 5 6; do
docker pull "$image" && break
[ "$attempt" -lt 6 ] || { echo "::error::could not pull $image"; exit 1; }
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
sleep $((attempt * 30))
done
docker image inspect "$image" --format 'image id {{.Id}} created {{.Created}}'
docker save "$image" | zstd -19 -T0 -o debian-bullseye.tar.zst
sha256sum debian-bullseye.tar.zst | tee SHA256SUMS
ls -l debian-bullseye.tar.zst
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" -R "$GITHUB_REPOSITORY" --title "CI Debian 11 image ($TAG)" --latest=false \
--notes "The debian:bullseye image build.yml compiles the x86 preloads in (glibc 2.31), saved with docker save and compressed with zstd. Pulled from public.ecr.aws/docker/library/debian:bullseye on $(date -u +%Y-%m-%d); image id $(docker image inspect "$image" --format '{{.Id}}'). Pinned in tools/linuxfs/debian-image.env."
fi
gh release upload "$TAG" -R "$GITHUB_REPOSITORY" debian-bullseye.tar.zst SHA256SUMS --clobber
{ echo "Published \`$TAG\`:"; echo; echo '```'; cat SHA256SUMS; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
+1
View File
@@ -13,6 +13,7 @@ captures/
app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/.proot-outputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
+5 -1
View File
@@ -25,7 +25,11 @@ Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview bu
## Build
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust through rustup, GitHub CLI, Python 3, and `zstd` installed. It prepares the same native libraries, pinned components and audio sinks as CI, then checks the APK's assets, JNI exports and dependencies. The APK is at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. Set `DROIDDECK_SIGNING_ENV` to a signing environment file to install over a release build; relative keystore paths resolve beside that file. To install on an attached device, run `tools/deploy_local.sh`.
After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too.
Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers.
## Limits
+48 -3
View File
@@ -65,6 +65,13 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
}
if (!file('src/main/assets/desktop-removal/versions.txt').readLines().contains(desktopKde.DESKTOP_KDE_TAG)) {
throw new GradleException('Regenerate desktop removal inventories for ' + desktopKde.DESKTOP_KDE_TAG)
}
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
.findAll { it.trim() && !it.startsWith('#') }
@@ -94,6 +101,7 @@ android {
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\""
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
@@ -198,6 +206,32 @@ def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
}
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
// Prepared audio lives under build/, so native builds never rewrite the tracked base bundle.
// Copying into one asset root also prevents an old base bundle winning a duplicate asset merge.
def runtimeAssetsDir = layout.buildDirectory.dir('runtimeAssets')
def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
dependsOn(stageSessionScripts)
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
from(layout.buildDirectory.dir('prepared-assets'))
into(runtimeAssetsDir)
}
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
// Unit tests and previews remain usable without Docker. Packaging an APK requires the complete
// runtime payload prepared by the local helper or CI, with matching sources and output hashes.
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
// The session scripts are re-copied into the asset tree by stageSessionScripts; with parallel
// task execution this check once hashed a file mid-copy and failed a main build. Hash only
// once the staging has finished.
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
workingDir rootProject.projectDir
commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify'
}
tasks.configureEach { task ->
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
}
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
@@ -214,6 +248,11 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
onlyIf { !skipRust }
inputs.dir(rustCrateDir.dir('src'))
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
inputs.property('ndkVersion', android.ndkVersion)
if (!skipRust) inputs.property('rustToolchain', providers.exec {
environment 'PATH', "${System.getProperty('user.home')}/.cargo/bin${File.pathSeparator}${System.getenv('PATH')}"
commandLine 'rustup', 'run', 'stable', 'rustc', '-Vv'
}.standardOutput.asText)
outputs.dir(rustJniLibsDir)
workingDir rustCrateDir
// The configuration cache serialises the task, so everything it needs is resolved here, not
@@ -223,12 +262,18 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
environment 'CARGO_TERM_COLOR', 'never'
if (!skipRust) environment 'RUSTC', providers.exec {
commandLine 'rustup', 'which', '--toolchain', 'stable', 'rustc'
}.standardOutput.asText.get().trim()
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
commandLine 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
// Homebrew's cargo/rustc can precede rustup on macOS and use a different sysroot, despite
// rustup reporting the Android target installed. Run both through the selected toolchain.
commandLine 'rustup', 'run', 'stable', 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
'build', '--release'
'build', '--release', '--locked'
}
android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
// Skipping Rust must omit its library even if another build left a cached .so behind.
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
+3
View File
@@ -30,6 +30,9 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
Granted at install below targetSdk 31; no scan, so no location. -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
Binary file not shown.
@@ -0,0 +1,49 @@
{
"archives": {
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
},
"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",
"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"
},
"legacyApplications": {
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
"labwc-0.20.2-1-aarch64.pkg.tar.xz": "bcd4cc3945165b0fdd33c0117cd7b9dfd05cf8c327326c37f3c3a27ef23ee5ca",
"lximage-qt-2.4.0-1-aarch64.pkg.tar.xz": "5d6bdd5472ea6bcb4b993b36df3ab7010bb2e62b36a78a649708fc9a52cb0928",
"lxqt-config-2.4.0-1-aarch64.pkg.tar.xz": "4b601d798fc7689ee76787dbef8c8958c02429ee60248cd99d950c919dbefba5",
"lxqt-panel-2.4.1-1-aarch64.pkg.tar.xz": "a141e0f8049e73f859d843510c6f45d6ed9087c990bcebf44f5f2430621024c4",
"lxqt-qtplugin-2.4.0-3-aarch64.pkg.tar.xz": "61c8f828be73111ee0bd5e3163b0d64c2098dd0189710a190f42be11137695cf",
"lxqt-runner-2.4.0-1-aarch64.pkg.tar.xz": "5afa74652a37366ea4e7774cf33379977582fbdca599cef1005ebf637cbcae4b",
"lxqt-session-2.4.0-2-aarch64.pkg.tar.xz": "e9c7c8a0011859029622ad202e0d3826e4e5bc8d64659031f6efa211456ff6eb",
"lxqt-themes-2.4.0-1-any.pkg.tar.xz": "7ea16c62a1fa70cdac13cb3f8fdcdaad299ae1f5bd514acfda671484755787bf",
"lxqt-wayland-session-0.4.1-1-any.pkg.tar.xz": "cd8dc11f5035469d23b6bbd937a90593402428c43de37517412aa43fe1fc6b10",
"papirus-icon-theme-20260801-1-any.pkg.tar.xz": "4aae3f47b800a60d6fd360e55adc581408ceaafc481c5a420a681741a3966510",
"pcmanfm-qt-2.4.1-1-aarch64.pkg.tar.xz": "7ec1a6b39dd0e4055b39f0e2c0549ea263d83004e1b821fb9e42f1d7f5fff0f9",
"qterminal-2.4.0-1-aarch64.pkg.tar.xz": "aa4adc9c9d47641b9452ec8d879038a9cb4762c3b6b5299045fac968fd0ed22d",
"swaybg-1.2.2-1-aarch64.pkg.tar.xz": "a26caa16ca7d581b5ca05758677058b4b60af10dc8d83e447ad6824b762b48b8",
"wlr-randr-0.5.0-1-aarch64.pkg.tar.xz": "58c3651c6a15dc8f95a310c7e225f759aa766be2fb3c0981a9a8b567854675b3"
}
}
@@ -0,0 +1,2 @@
desktop-kde-r1
desktop-kde-r2
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -136,6 +136,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
argv[i] = strdup(arg_utf8);
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
(*env)->DeleteLocalRef(env, arg_java_string);
}
argv[size] = NULL;
}
@@ -151,6 +152,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
envp[i] = strdup(env_utf8);
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
(*env)->DeleteLocalRef(env, env_java_string);
}
envp[size] = NULL;
}
@@ -160,7 +162,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
(*env)->ReleaseStringUTFChars(env, cwd, cmd_cwd);
if (argv) {
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
@@ -22,8 +22,6 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
// Before anything else touches the session prefs, so the first session already runs in Deck mode.
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
GpuClockPin.clearLeftover(this)
@@ -14,6 +14,7 @@ class HomeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
routeHome()
}
@@ -10,6 +10,7 @@ import java.io.File
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -17,6 +18,7 @@ import android.util.Log
import android.view.Display
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -119,7 +121,6 @@ class MainActivity : ComponentActivity() {
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
@@ -139,6 +140,7 @@ class MainActivity : ComponentActivity() {
private var pkgStage by mutableStateOf<String?>(null)
private var pkgPercent by mutableIntStateOf(-1)
private var desktopInstalled by mutableStateOf(false)
private var desktopPresent by mutableStateOf(false)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
@@ -161,6 +163,7 @@ class MainActivity : ComponentActivity() {
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
@@ -301,29 +304,34 @@ class MainActivity : ComponentActivity() {
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
addedGamesArt = SessionPrefs.addedGamesArt(this)
refreshAddedGames()
refresh()
}
/** The Games tab's +: the picked .exe becomes a game (or selects / switches the one it belongs to). */
private fun addGameExe(path: String) {
val exe = java.io.File(path)
Thread({
val result = try { com.droiddeck.launcher.frontend.AddedGames.addExe(this, exe) } catch (e: Exception) {
Log.w(TAG, "add game: ${e.message}"); com.droiddeck.launcher.frontend.AddedGames.AddResult.NotFound
}
val game = when (result) {
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Added -> result.game
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Existing -> result.game
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Switched -> result.game
else -> null
}
ui.post {
if (game == null) {
if (result is com.droiddeck.launcher.frontend.AddedGames.AddResult.Unreachable)
android.widget.Toast.makeText(this, R.string.games_add_unreachable, android.widget.Toast.LENGTH_LONG).show()
return@post
}
updateAddedGames(listOf(game.folder.path), then = { fetchAddedGameArt() })
navRequest = "app:${game.steamAppId ?: game.appId.toInt()}"
}
}, "add-game-exe").start()
}
private var pendingAddedGame: String? = null
private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val folder = pendingAddedGame ?: return@registerForActivityResult
pendingAddedGame = null
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setAddedGameExe(this, folder, path)
refreshAddedGames()
refresh()
}
}
private var addedGamesDirs by mutableStateOf<List<String>>(emptyList())
private var addedGamesArt by mutableStateOf(true)
@Volatile private var artFetchRunning = false
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
/** The user entered a SteamGridDB API key of their own. */
private var sgdbUserKey by mutableStateOf(false)
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setRomsDir(this, path)
@@ -335,7 +343,6 @@ class MainActivity : ComponentActivity() {
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
@@ -357,6 +364,8 @@ class MainActivity : ComponentActivity() {
}
private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
@Volatile private var componentSweepDone = false
@Volatile private var componentSweepSession: String? = null
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
@@ -526,6 +535,8 @@ class MainActivity : ComponentActivity() {
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
@@ -555,7 +566,7 @@ class MainActivity : ComponentActivity() {
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
@@ -583,6 +594,7 @@ class MainActivity : ComponentActivity() {
updates = updates.state(),
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
@@ -590,23 +602,46 @@ class MainActivity : ComponentActivity() {
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
navigate = navRequest,
addedGamesArt = addedGamesArt,
sgdbUserKey = sgdbUserKey,
storesShowMature = storesShowMature,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
// Steam's desktop client as a window on the desktop: under gamescope the
// client puts itself into Big Picture whatever it is started with.
// The client's desktop UI, with gamescope's Deck integration disabled.
onPlayDesktopUi = {
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
// A store game may need something done first (an Epic game its sign-in code).
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
onSteamGame = { g ->
if (shortcutPicker) chooseGameShortcut(g)
else {
// The launch takes the Windows components already on; the game's list is
// looked at again in the background once the session is over.
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
}
},
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
onAddGameExe = { path -> addGameExe(path) },
onAddedGamesChanged = { folders ->
// New games want art; an edit does not.
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
},
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onSgdbKey = { key ->
Thread({
com.droiddeck.launcher.frontend.SteamGridDb.setUserKey(this, key)
ui.post { sgdbUserKey = key.isNotBlank(); refreshAddedGames() }
}, "sgdb-key").start()
},
onStopGameFileSync = {
Thread({
GameFileSync.disable(this)
@@ -649,6 +684,7 @@ class MainActivity : ComponentActivity() {
// The activity re-attaches to the session that is running; nothing restarts.
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
onRemoveDesktop = { removeDesktop() },
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
@@ -713,6 +749,11 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onOrientation = { value ->
SessionPrefs.setOrientation(this, value)
orientation = value
requestedOrientation = value
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
@@ -802,6 +843,7 @@ class MainActivity : ComponentActivity() {
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
@@ -871,6 +913,7 @@ class MainActivity : ComponentActivity() {
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
com.droiddeck.launcher.frontend.GamesFolderMigration.run(this)
SessionLogShare.clear(this)
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
@@ -912,6 +955,15 @@ class MainActivity : ComponentActivity() {
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = if (launcherFullscreen) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
}
}
}
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
@@ -938,6 +990,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
refreshWifiDiscovery()
// Back from a store's sign-in page: the Stores chips show the account at once. A store
// install finishing while this screen is up rebuilds the Games list through the listener.
@@ -1178,6 +1232,7 @@ class MainActivity : ComponentActivity() {
private fun refreshPackages() {
packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
}
private fun launchProgram(path: String) {
@@ -1231,6 +1286,8 @@ class MainActivity : ComponentActivity() {
steamDownloadsInBackground = steamDownloadsInBackground,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
@@ -1242,8 +1299,6 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1253,9 +1308,6 @@ class MainActivity : ComponentActivity() {
wifiDiscoveryLocation = wifiDiscoveryLocation,
wifiDiscoveryAsked = wifiDiscoveryAsked,
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
@@ -1291,6 +1343,7 @@ class MainActivity : ComponentActivity() {
steamDownloadsInBackground = enabled
},
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
@@ -1323,27 +1376,13 @@ class MainActivity : ComponentActivity() {
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
onPickAddedGameExe = { folder ->
pendingAddedGame = folder
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
},
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
@@ -1398,18 +1437,44 @@ class MainActivity : ComponentActivity() {
)
}
/** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */
/** The added games for the session settings: a folder walk, so off the main thread. */
private fun refreshAddedGames() {
/**
* The Games tab after these added games were added or edited: each read alone and put in
* place of its old entry (or added), no library-wide scan. [then] runs after, on the main thread.
*/
private fun updateAddedGames(folders: List<String>, then: () -> Unit = {}) {
if (folders.isEmpty()) return
Thread({
val games = scanAddedGames()
ui.post { addedGames = games; fetchAddedGameArt() }
}, "added-games").start()
val fresh = folders.mapNotNull { f ->
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
}
// An added or edited game's Windows components, from its source's list.
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(this, it) }
ui.post {
val paths = folders.toSet()
steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId }
then()
}
}, "added-games-update").start()
}
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
/** An added game removed: off the Games tab now; the rest (cache, Steam, listing) off the main thread. */
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
}
/** After the added games changed: art that is missing is fetched (off the main thread). */
private fun refreshAddedGames() {
sgdbKeyKnown()
fetchAddedGameArt()
}
/** Whether the user has a SteamGridDB key of their own, for Setup; read off the main thread. */
private fun sgdbKeyKnown() {
Thread({
val set = com.droiddeck.launcher.frontend.SteamGridDb.userKey(this).isNotEmpty()
ui.post { sgdbUserKey = set }
}, "sgdb-key-state").start()
}
private fun fetchAddedGameArt() {
@@ -1429,9 +1494,11 @@ class MainActivity : ComponentActivity() {
}
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private fun openModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
showPerformance = false
showProtons = false
showComponents = false
@@ -1442,13 +1509,10 @@ class MainActivity : ComponentActivity() {
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
@@ -1470,12 +1534,10 @@ class MainActivity : ComponentActivity() {
// added-games folders, the storage volumes) lands while it animates in.
Thread({
drivers.refreshDrivers()
val games = scanAddedGames()
val storage = GameStorage.options(this)
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
ui.post {
addedGames = games
storageOptions = storage
if (mode == SessionService.MODE_STEAM) {
decky.deckyInstalled = deckyInstalled
@@ -1505,7 +1567,6 @@ class MainActivity : ComponentActivity() {
clientOverride = SessionPrefs.clientCpusOverride(this)
glThread = SessionPrefs.glThread(this)
noGlError = SessionPrefs.noGlError(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() }
gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() }
tuSysmem = SessionPrefs.tuSysmem(this)
@@ -1524,7 +1585,9 @@ class MainActivity : ComponentActivity() {
private fun refresh() {
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
addedGamesArt = SessionPrefs.addedGamesArt(this)
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
@@ -1544,13 +1607,39 @@ class MainActivity : ComponentActivity() {
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// One update with the whole list: the wall places games by their position in it, so a
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
// covers the wait.
// Steam's own games first, from their manifests alone (quick), beside the added games
// already listed: a game Steam installed during the session shows as the session ends,
// without waiting for the added games' walk below. Its Windows components' list is
// queued then too, on the low-priority worker, so its page is usually ready.
if (ready) {
val quick = runCatching { Library.steamGames(this) }.getOrNull()
if (quick != null) ui.post {
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
val before = steamGames.map { it.gameId }.toSet()
val fresh = quick.filter { it.gameId !in before }
if (fresh.isNotEmpty()) {
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
}
}
}
}
// Then one update with the whole list: the wall places games by their position in it,
// so a partial list first would shuffle every capsule when the rest arrived.
// LibraryCache covers the wait.
val games = if (ready) Library.launchableGames(this) else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
// Once per app start: the Steam games' Windows components from Steam's lists.
// Queued only, after the launcher has been up a little; the worker takes them one at a time.
// And again after each session (a game installed in it, files changed).
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
if (ready && (!componentSweepDone || endedSession != null)) {
componentSweepDone = true
endedSession?.let { componentSweepSession = it }
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
}
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
@@ -1637,6 +1726,15 @@ class MainActivity : ComponentActivity() {
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeDesktop() {
if (runtimeChangesBlocked()) return
val operation = LinuxRuntimeInstaller.beginMaintenance(this, getString(R.string.desktop_removing)) ?: return
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { listener ->
operation.run(listener) { progress -> DesktopCatalog.removeDesktop(this, progress) }
}
}
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
@@ -1659,7 +1757,7 @@ class MainActivity : ComponentActivity() {
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
stage = getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
@@ -19,6 +19,7 @@ import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.Display
import android.view.SurfaceHolder
import android.view.Gravity
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
@@ -51,6 +52,7 @@ import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SessionClipboard
import com.droiddeck.launcher.input.OnScreenControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
@@ -109,6 +111,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
/** Unfolded controls: the setting, and the Deck controls under the game while they show. */
private var unfoldedControls by mutableStateOf(false)
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
private lateinit var sessionRoot: FrameLayout
private val guestKeysDown = mutableSetOf<Int>()
private lateinit var surfaceView: SurfaceView
private lateinit var sessionOverlay: ComposeView
@@ -119,7 +125,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } }
/**
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
@@ -132,6 +138,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun openDeckRing(why: String) {
val deck = SessionState.deckPad
val opened = if (deck) padBridge?.start() else null
// Every pad already connected is a player from the start, before its first press.
padBridge?.claimConnected()
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
null -> ", left for first input"
true -> ", ring open"
@@ -142,9 +150,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
else padMotion?.stop()
}
private var steamController: SteamControllerBle? = null
private var onScreenControls: OnScreenControls? = null
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
private var steamTouchAvailable by mutableStateOf(false)
private val steamTouchRetry = Runnable { updateOnScreenControls() }
/** While Steam's touch controls are up: how the game's touch config says touches outside them
* act (Steam Link's input and mouse modes), in place of the drawer's touch mode. */
private var steamTouchOptions: com.droiddeck.launcher.input.SteamTouchConfig.Options? = null
/** The touch mode touches are routed by: the drawer's, or the game's while Steam's controls are up. */
private var routedTouchMode = SessionPrefs.TOUCH_AUTO
private var keyboard: KeyboardHost? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private val sessionClipboard by lazy {
@@ -232,6 +252,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** What the pad is to the Steam client (SessionPrefs.CONTROLLER_*), switched from the drawer. */
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
@@ -290,11 +312,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.claimConnected()
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
padBridge?.onDeviceRemoved(deviceId)
updateOnScreenControls()
updatePointerCapture()
}
@@ -305,7 +329,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
requestedOrientation = SessionPrefs.orientation(this)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
finish()
@@ -359,13 +385,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
}
}
sessionRoot = root
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
// The window's size is only known once laid out, and changes as the phone folds or turns.
root.addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
if (r - l != or - ol || b - t != ob - ot) uiHandler.post { applyUnfoldedControls() }
}
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
padBridge = bridge
SessionState.padBridge = bridge
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
steamController = SteamControllerBle(this).also { it.start() }
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
@@ -557,12 +592,30 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// Only where the session set the pad up to be switched (SessionService: deckPad).
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
onPadType = { id ->
SessionPrefs.setSteamController(this@SessionActivity, id)
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
padType = id
},
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onOsc = { v ->
SessionPrefs.setOscMode(this@SessionActivity, v)
readPrefs()
updateOnScreenControls()
if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null)
Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show()
},
onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({
drawerOpen = false
steamTouchControls?.startEditing()
}) else null,
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
@@ -599,6 +652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
pipAutoEnter = on
pip.refresh(true)
},
unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null,
onUnfoldedControls = ::chooseUnfoldedControls,
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
@@ -779,6 +834,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
hudOn = SessionPrefs.hudEnabled(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
@@ -794,15 +850,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendSceneInput(3, it, 0)
}
}
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
}
touchMode = nextTouchMode
routedTouchMode = effectiveTouchMode()
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
padType = SessionPrefs.steamController(this)
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
@@ -1103,6 +1161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -1330,7 +1389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchKeyEvent(event)
}
if (pipUi) return true
val fromController = event.device != null && PadBridge.isFromController(event.device)
val fromController = PadBridge.isControllerKeyEvent(event)
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
@@ -1382,12 +1441,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event)
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
// activity, which opens the drawer.
val fromPad = event.device != null && PadBridge.isFromController(event.device)
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
@@ -1425,7 +1483,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
if (!drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
@@ -1436,7 +1494,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event)
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
return super.dispatchGenericMotionEvent(event)
@@ -1660,17 +1718,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
},
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
onSteamMenu = {
if (SessionState.mode == SessionService.MODE_STEAM) {
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
Handler(Looper.getMainLooper()).postDelayed({
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
},
onQam = {
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
},
onSteamMenu = ::tapSteamButton,
onQam = ::tapQam,
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
)
secondScreenPresentation = presentation
@@ -1687,6 +1736,64 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun tapSteamButton() {
if (SessionState.mode == SessionService.MODE_STEAM) {
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
Handler(Looper.getMainLooper()).postDelayed({
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
}
private fun tapQam() {
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
}
private fun chooseUnfoldedControls(on: Boolean) {
SessionPrefs.setUnfoldedControls(this, on)
unfoldedControls = on
applyUnfoldedControls()
}
/**
* Unfolded controls: on a foldable's inner screen, the game above and the Steam Deck controller's
* trackpads and back grips below ([com.droiddeck.launcher.ui.DeckControlsPanel], as a second
* screen shows them, plus a whole gamepad). The on-screen pad is hidden meanwhile. The surface shrinks to the top part, so a Follow screen session resizes
* to it and any other is fitted into it. Folded onto the cover screen - a phone-sized panel -
* or in picture-in-picture, the game is full screen again.
*/
private fun applyUnfoldedControls() {
if (!::sessionRoot.isInitialized) return
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
else FrameLayout.LayoutParams.MATCH_PARENT
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
if (params.height != gameHeight) {
surfaceView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, gameHeight, Gravity.TOP)
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
else "unfolded controls: game full screen")
}
var panel = unfoldedPanel
if (!wanted) {
if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() }
return
}
if (panel == null) {
panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) },
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge)
// Just above the surface: the on-screen pad, keyboard and drawer still draw over it.
sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1)
unfoldedPanel = panel
updateOnScreenControls()
}
panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
}
private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) {
if (width <= 0f || height <= 0f) return
val nx = (x / width).coerceIn(0f, 1f)
@@ -1707,8 +1814,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
/** The touch mode in force: the game's own while Steam's touch controls are shown. */
private fun effectiveTouchMode(): String {
val options = steamTouchOptions
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
return when {
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
else -> SessionPrefs.TOUCH_DIRECT
}
}
private fun applyTouchRouting() {
val next = effectiveTouchMode()
if (::touchpad.isInitialized) {
val speed = steamTouchOptions?.trackpadSensitivity?.takeIf { steamTouchControls?.visibility == View.VISIBLE } ?: 1f
touchpad.sensitivity = 1.15f * speed
}
if (next == routedTouchMode) return
if (::touchpad.isInitialized) touchpad.cancel()
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
routedTouchMode = next
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) {
private fun usingTouchpad(): Boolean = when (routedTouchMode) {
SessionPrefs.TOUCH_PAD -> true
SessionPrefs.TOUCH_DIRECT -> false
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
@@ -1730,7 +1861,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
// Child views get the event first, so menus, keyboards and the on-screen pad still work.
if (touchMode == SessionPrefs.TOUCH_OFF) return true
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
if (usingTouchpad()) {
val rect = drawnRect() ?: return false
if (touchpad.bounds != rect) {
@@ -1849,30 +1980,97 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
what, d.name, d.vendorId, d.productId, d.id, d.sources))
}
/** Steam's touch controls, once the session has made the device (SessionService). */
private fun steamTouch(): com.droiddeck.launcher.input.SteamTouchControls? {
val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return null
steamTouchControls?.let { view ->
if (view.device === device) return view
// A new session made a new device: the view of the old one goes.
view.releaseAll()
(view.parent as? android.view.ViewGroup)?.removeView(view)
steamTouchControls = null
}
val root = onScreenControls?.parent as? android.view.ViewGroup ?: return null
val view = com.droiddeck.launcher.input.SteamTouchControls(this, device,
onMenu = { drawerOpen = true }, onKeyboard = ::togglePcKeyboard)
view.visibility = View.GONE
view.onOptions = { options ->
steamTouchOptions = options
applyTouchRouting()
}
// Paste: the clipboard is shared with the session's X servers; Ctrl+V types it.
view.onPaste = {
sendGuestKey(29, true); sendGuestKey(47, true) // KEY_LEFTCTRL, KEY_V
sendGuestKey(47, false); sendGuestKey(29, false)
}
// The client reads a controller's config when it connects: after a save, plug the device
// out and back in so it takes the new one.
view.onSaved = {
device.plugged = false
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
}
root.addView(view, root.indexOfChild(onScreenControls) + 1)
steamTouchControls = view
steamTouchAvailable = true
return view
}
private fun updateOnScreenControls() {
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
.takeIf { it.isFile }
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi) {
// In picture-in-picture, or with the unfolded screen's gamepad below the game, the on-screen
// pad would only cover the picture or that panel.
if (pipUi || unfoldedPanel != null) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
return
}
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
// Steam's touch controller stands in for the app's pad, and is plugged into the client only
// while it is shown - as Steam Link withdraws its controls when a physical pad is in use.
val wantsSteam = forced == SessionPrefs.OSC_STEAM_TOUCH && SessionState.mode == SessionService.MODE_STEAM
val steam = if (wantsSteam) steamTouch() else null
// The service makes the device while the session starts, after this activity: the app's
// own pad until then, and another look in a moment.
if (wantsSteam && steam == null && !isFinishing && !isDestroyed) {
uiHandler.removeCallbacks(steamTouchRetry)
uiHandler.postDelayed(steamTouchRetry, 1000)
}
if (steam == null) {
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
}
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
SessionPrefs.OSC_ALWAYS -> true
SessionPrefs.OSC_STEAM_QAM -> true
SessionPrefs.OSC_NEVER -> false
// Shown throughout a Steam session: a handheld's built-in pad would keep Auto off for good.
// Outside Steam it is Auto.
SessionPrefs.OSC_STEAM_TOUCH -> SessionState.mode == SessionService.MODE_STEAM ||
(!PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP)
// Auto: the touch pad when there is no controller - except on the desktop, where the
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (steam != null) {
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!show) steam.releaseAll()
steam.visibility = if (show) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
applyTouchRouting()
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
return
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
@@ -1946,6 +2144,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::pip.isInitialized) pip.changed(inPip)
// The panel may have changed while the session was a thumbnail (folded or unfolded in PiP).
if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH)
applyUnfoldedControls()
}
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
@@ -2002,6 +2201,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
requestedOrientation = SessionPrefs.orientation(this)
if (runtimeRemovalBlocked) return
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
@@ -2096,6 +2296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
uiHandler.removeCallbacks(steamTouchRetry)
if (runtimeRemovalBlocked) { super.onDestroy(); return }
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
@@ -2107,6 +2308,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
steamController?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
@@ -14,6 +14,7 @@ import org.json.JSONObject
class AgentStartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
if (request == null) {
finish()
@@ -182,26 +182,30 @@ public class PulseAudioComponent extends SessionPart {
* client is never left without an output. Says so in the audio log either way.
*/
private void ensureClientSink(java.io.PrintWriter out) {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
try {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
} catch (Throwable t) {
Log.w(TAG, "ensureClientSink failed", t);
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
}
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
@@ -223,8 +227,14 @@ public class PulseAudioComponent extends SessionPart {
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
String output;
try (InputStream in = process.getInputStream()) {
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
try (InputStream in = process.getInputStream();
java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
outStream.write(buf, 0, n);
}
output = outStream.toString(java.nio.charset.StandardCharsets.UTF_8.name());
}
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
@@ -234,7 +244,7 @@ public class PulseAudioComponent extends SessionPart {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
} catch (Throwable t) {
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
@@ -152,7 +152,6 @@ object DeviceReport {
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
@@ -23,4 +23,8 @@ object DeviceSupport {
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
}
/** False on the Odin 3 (CQ8725S): a low-latency Wi-Fi lock makes its WLAN firmware hang, and
* that firmware reboots the whole device instead of restarting Wi-Fi. */
fun lowLatencyWifiSafe(): Boolean = Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S"
}
@@ -31,7 +31,8 @@ object GameEnvironment {
"VKD3D_SHADER_MODEL" to "6_9",
// .NET 7 and later reserve 256 GB of address space for the GC heap at startup, more than an
// Android process has free, and the runtime fails to start. 32 GB is plenty for a game.
"DOTNET_GCRegionRange" to "0x800000000",
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
"DOTNET_GCRegionRange" to "800000000",
// .NET's W^X writes JIT code through a second mapping that FEX does not watch, so FEX keeps
// running the stale translation and the game hangs at startup.
"DOTNET_EnableWriteXorExecute" to "0",
@@ -93,9 +93,12 @@ object LogRedactor {
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
/** A learned pattern and the text (lowercase) a line must hold for it to match; null when none can be said. */
private class Learned(val regex: Regex, val needs: String?)
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
@Volatile
private var accounts: List<Regex> = emptyList()
private var accounts: List<Learned> = emptyList()
/**
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
@@ -103,12 +106,13 @@ object LogRedactor {
* Names under three characters are skipped: blanking every "a" would ruin a log.
*/
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Regex>()
val found = ArrayList<Learned>()
try {
if (loginUsers.isFile) {
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
}
}
} catch (e: Exception) {
@@ -125,7 +129,7 @@ object LogRedactor {
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
@Volatile
private var own: List<Regex> = emptyList()
private var own: List<Learned> = emptyList()
/**
* Learns the device's addresses from the link file the app writes for the session
@@ -133,7 +137,7 @@ object LogRedactor {
* private, link-local and loopback addresses identify nobody and are left out.
*/
fun learnOwnAddresses(linkFile: java.io.File) {
val found = ArrayList<Regex>()
val found = ArrayList<Learned>()
try {
if (linkFile.isFile) linkFile.forEachLine { line ->
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
@@ -142,9 +146,12 @@ object LogRedactor {
val groups = expand6(addr)?.take(4) ?: return@forEachLine
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
found += Learned(
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
groups[0].trimStart('0').ifEmpty { "0" },
)
}
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
}
}
} catch (e: Exception) {
@@ -263,41 +270,87 @@ object LogRedactor {
return "$q<redacted:account>$q"
}
/**
* A line on its way through [redact]. Each rule runs only on a line holding what it cannot match
* without - a word, enough separators, a long enough run: most of a Steam log is lines no rule
* touches, and a session's logs went through every rule at about a megabyte a second. A line with anything but ASCII in it goes through
* every case-insensitive rule, since Android's regex folds case more widely than lowercase().
*/
private class Line(var text: String) {
private val unfolded = text.any { it.code >= 0x80 }
private var lower: String? = null
fun has(vararg words: String): Boolean {
if (unfolded) return true
val folded = lower ?: text.lowercase().also { lower = it }
return words.any { folded.contains(it) }
}
fun set(next: String) {
if (next != text) {
text = next
lower = null
}
}
fun holds(c: Char, times: Int): Boolean {
var n = 0
for (ch in text) if (ch == c && ++n >= times) return true
return false
}
inline fun hasRun(length: Int, inRun: (Char) -> Boolean): Boolean {
var n = 0
for (ch in text) if (!inRun(ch)) n = 0 else if (++n >= length) return true
return false
}
}
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
if (line.isEmpty()) return line
return try {
var out = line
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
out = MAC.replace(out, "<redacted:mac>")
out = DEVICE_IDENTIFIER.replace(out) {
val out = Line(line)
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
val value = it.groupValues[2]
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
})
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
}
out = GUID.replace(out, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
out = WEBAPI_KEY.replace(out, "<redacted:key>")
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
// Mask, not delete: the last four digits let a reader correlate lines to one account.
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
out = STEAMID3.replace(out) { m ->
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
val id = m.groupValues[1]
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
})
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
if (out.has("account", "user")) {
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
}
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
for (r in own) out = r.replace(out, "<redacted:ip>")
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
for (r in accounts) out = r.replace(out, "<redacted:account>")
out = EMAIL.replace(out, "<redacted:email>")
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
out = LONG_TOKEN.replace(out, "<redacted:token>")
out
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
}
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
}
out.text
} catch (t: Throwable) {
// A log line is never worth crashing a session over, but an unscrubbed one must not
// reach the file either.
@@ -22,6 +22,7 @@ class FileManagerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
}
@@ -35,6 +35,7 @@ class FilePickerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
// targetSdk 28: the old storage permission is exactly what lets us list the card.
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Games added from the Games tab's + rather than a Games folder: one picked .exe, or each game
* "Add all games in this folder" found. Kept by the game's folder in `files/added-exes.json`,
* which [AddedGames.scan] merges in. The exe chosen later, the shortcut appid, the edits and the
* removal live where every added game keeps them ([com.droiddeck.launcher.session.SessionPrefs]);
* this holds which folders are games and the exe they came with.
*/
object AddedExes {
/** [picked]: the user chose [exe]; false = [GameExePicker]'s best guess, which a scan ranks again. */
class Entry(val folder: String, val exe: String, val picked: Boolean = true)
private fun file(context: Context) = File(context.filesDir, "added-exes.json")
@Synchronized
fun list(context: Context): List<Entry> = try {
val array = JSONArray(file(context).takeIf { it.isFile }?.readText() ?: "[]")
(0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
val folder = o.optString("folder"); val exe = o.optString("exe")
if (folder.isEmpty() || exe.isEmpty()) null else Entry(folder, exe, o.optBoolean("picked", true))
}
} catch (e: Exception) {
emptyList()
}
/** Adds [entry], replacing one for the same folder. */
@Synchronized
fun put(context: Context, entry: Entry) = write(context, list(context).filter { it.folder != entry.folder } + entry)
@Synchronized
fun drop(context: Context, folder: String) = write(context, list(context).filter { it.folder != folder })
private fun write(context: Context, entries: List<Entry>) {
val array = JSONArray()
entries.forEach { array.put(JSONObject().put("folder", it.folder).put("exe", it.exe).put("picked", it.picked)) }
val f = file(context)
val tmp = File(f.path + ".tmp")
tmp.writeText(array.toString())
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
}
@@ -12,8 +12,9 @@ import java.net.URL
import java.net.URLEncoder
/**
* Artwork for an added game: what the user put in the game's folder first, else what Steam's
* store has for the same title, fetched once into the app's cache. Four pieces, named the way
* Artwork for an added game: what the user put in the game's folder first, else what Steam has
* for it - by the Steam appid its files name, else by a store search on its title - else what
* SteamGridDB has ([SteamGridDb]), fetched once into the app's cache. Four pieces, named the way
* the client names them in its grid folder: the portrait capsule, the wide header, the hero
* background and the logo; plus an icon pulled out of the game's own .exe. The session hands
* the paths to the runtime's shortcuts writer, which copies them into the client's grid.
@@ -28,13 +29,40 @@ object AddedGameArt {
val any get() = portrait != null || header != null || hero != null || logo != null
}
/**
* The pieces a user can choose, as the client takes them: [assetType] is its custom-artwork
* number (null: the icon, set by path); [cacheName] the automatic file in the game's cache.
*/
enum class Slot(val key: String, val cacheName: String, val assetType: Int?) {
COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
}
/** Where a piece came from; [AUTO] = nothing found yet. */
const val FOLDER = "folder"
const val STEAM = "steam"
const val SGDB = "sgdb"
const val FILE = "file"
const val EXE = "exe"
const val AUTO = ""
/** A slot as it stands: the file shown and where it came from (one of [FOLDER].. [AUTO]); [chosen] = the user picked it. */
class SlotState(val slot: Slot, val file: File?, val source: String, val chosen: Boolean)
/** One image the chooser offers: its group ([FOLDER], [STEAM], [SGDB]), a thumbnail and the full image (URL or path). */
class Option(val group: String, val thumb: String, val full: String)
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
private fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
private val IMAGE = listOf("png", "jpg", "jpeg")
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
private val HEADER = listOf("header", "banner", "library_header", "wide")
private val HERO = listOf("hero", "background", "library_hero")
private val LOGO = listOf("logo")
private fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
internal fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
/** An image in the folder (or its `art` subfolder) under one of the given names, any image extension. */
private fun find(folder: File, names: List<String>): File? {
@@ -58,20 +86,111 @@ object AddedGameArt {
)
}
/** The folder's art, else the cached Steam art, without touching the network. */
/** The user's choice for each piece, else the folder's art, else the cached art; no network. */
fun resolve(context: Context, game: AddedGames.Game): Art {
val own = local(game)
val cache = cacheDir(context, game)
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
return Art(
portrait = own.portrait ?: cached("p.jpg"),
header = own.header ?: cached("header.jpg"),
hero = own.hero ?: cached("hero.jpg"),
logo = own.logo ?: cached("logo.png"),
icon = own.icon ?: exeIcon(context, game),
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
header = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"),
icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game),
)
}
/** Each choosable piece with its file and where it came from, for the game's editor; no network. */
fun describe(context: Context, game: AddedGames.Game): List<SlotState> {
val own = local(game)
val cache = cacheDir(context, game)
fun cached(name: String): Pair<File, String>? = File(cache, name).takeIf { it.isFile }?.let { f ->
f to (File(cache, "$name.src").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: STEAM)
}
return Slot.values().map { slot ->
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
val auto: Pair<File, String>? = when (slot) {
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png")
Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB }
?: exeIcon(context, game)?.let { it to EXE }
}
SlotState(slot, auto?.first, auto?.second ?: AUTO, false)
}
}
private val CHOOSABLE = setOf("png", "jpg", "jpeg", "webp")
/** Images in the game's folder (and its art folders) that could stand for a piece. */
internal fun folderImages(folder: File): List<File> =
listOf(folder, File(folder, "art"), File(folder, ".art")).flatMap { dir ->
dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty()
}.take(12)
/**
* What the chooser offers for [slot]: the game folder's images, Steam's official piece (when the
* game has a Steam appid and Steam has the piece), and SteamGridDB's best few (with a key).
* Blocking: it asks the network.
*/
fun options(context: Context, game: AddedGames.Game, slot: Slot): List<Option> {
val out = ArrayList<Option>()
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
val cache = cacheDir(context, game)
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
if (steamId != null && remote != null) {
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
if (url != null) out.add(Option(STEAM, url, url))
}
val key = SteamGridDb.key(context)
if (key.isNotEmpty()) {
cache.mkdirs()
val gridId = remembered(File(cache, "sgdb-id")) {
runCatching { SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name) }.getOrNull()?.toString()
}?.toIntOrNull()
if (gridId != null) runCatching { SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5) }.getOrNull()
?.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
}
return out
}
/** Sets [slot] to [option] (a file is copied, a URL fetched) and records where it came from. False when it could not be had. Blocking. */
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
bytes ?: return false
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
val cache = cacheDir(context, game).apply { mkdirs() }
chosen(cache, slot)?.delete()
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
val tmp = File(cache, dst.name + ".tmp")
tmp.writeBytes(bytes)
if (!tmp.renameTo(dst)) return false
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
return true
}
/** Where the user's choice for [slot] came from and which image it was ("" both when automatic). */
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
val value = SessionPrefs.addedGameArtSource(context, game.folder.path, slot.key)
return value.substringBefore('|') to value.substringAfter('|', "")
}
/** [slot] back to what is found by itself. */
fun reset(context: Context, game: AddedGames.Game, slot: Slot) {
chosen(cacheDir(context, game), slot)?.delete()
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, "")
}
private fun exists(url: String): Boolean = runCatching {
val c = URL(url).openConnection() as HttpURLConnection
c.requestMethod = "HEAD"
c.connectTimeout = 8_000
c.readTimeout = 8_000
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
try { c.responseCode == 200 } finally { c.disconnect() }
}.getOrDefault(false)
/** The first icon inside the game's .exe, kept as a PNG beside the cached art. */
private fun exeIcon(context: Context, game: AddedGames.Game): File? {
val cache = cacheDir(context, game)
@@ -88,58 +207,91 @@ object AddedGameArt {
}
/**
* Looks the games up on Steam's store and fetches the art that is missing, once per game
* (a miss is retried after a week). Blocking: run it off the main thread. Returns true when
* anything new arrived.
* Fetches the art that is missing: Steam first (the appid the game's files name, else a store
* search on its title whose result carries the same name), then SteamGridDB when a key is
* set. Each lookup runs once per game; a miss is retried after a week. Blocking: run it off
* the main thread. Returns true when anything new arrived.
*/
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
if (!SessionPrefs.addedGamesArt(context)) return false
val sgdbKey = SteamGridDb.key(context)
var changed = false
for (game in games) {
if (local(game).portrait != null) continue
val cache = cacheDir(context, game)
val lookup = File(cache, "steam-appid")
if (File(cache, "p.jpg").isFile) continue
if (lookup.isFile && (lookup.readText().trim() != "none" || System.currentTimeMillis() - lookup.lastModified() < RETRY_AFTER_MS)) {
val id = lookup.readText().trim()
if (id == "none") continue
if (download(id, cache)) changed = true
continue
}
cache.mkdirs()
val id = runCatching { search(game.name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
if (id == null) { lookup.writeText("none"); continue }
lookup.writeText(id)
Log.i(TAG, "${game.name}: Steam app $id")
if (download(id, cache)) changed = true
val match = steamMatch(context, game) { name ->
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
}
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
val id = match.appId?.toString()
if (id != null && download(id, cache)) changed = true
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
val gridId = remembered(File(cache, "sgdb-id")) {
runCatching { SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name) }.getOrNull()?.toString()
}?.toIntOrNull() ?: continue
if (runCatching { SteamGridDb.download(sgdbKey, gridId, cache) }.getOrDefault(false)) changed = true
}
return changed
}
/**
* The game's Steam app, the one record its Windows components use too ([SteamMatch]): what its
* files name, else a store search on its title ([search]) whose result has the same name.
*/
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
val match = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cacheDir(context, game), "steam-appid"), search = search)
match.appId?.let { Log.i(TAG, "${game.name}: Steam app $it (${match.certainty.name.lowercase()})") }
return match
}
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
private fun remembered(marker: File, find: () -> String?): String? {
if (marker.isFile) {
val value = marker.readText().trim()
if (value != "none") return value
if (System.currentTimeMillis() - marker.lastModified() < RETRY_AFTER_MS) return null
}
val found = find()
marker.writeText(found ?: "none")
return found
}
private fun normalize(s: String) = s.lowercase().replace(Regex("[^a-z0-9]"), "")
/** The Steam appid whose store name matches the folder name best, or null for no match. */
private val EDITION = Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b")
/**
* How well a result's name [got] matches the game's [name]: 3 the same, 2 the same once
* edition words are dropped, 1 one starts with the other; 0 no match. Nothing is taken for
* being the first result.
*/
internal fun nameMatch(name: String, got: String): Int {
val want = normalize(name)
val wantShort = normalize(name.replace(EDITION, " ").trim().ifEmpty { name })
val have = normalize(got)
if (have.isEmpty() || wantShort.isEmpty()) return 0
return when {
have == want -> 3
have == wantShort -> 2
have.startsWith(wantShort) || wantShort.startsWith(have) -> 1
else -> 0
}
}
/** The id of the store result whose name matches [name] best, or null when none does. */
internal fun pickStoreResult(name: String, items: List<Pair<String, String>>): String? =
items.map { (id, got) -> id to nameMatch(name, got) }
.filter { it.second > 0 && it.first.isNotEmpty() }
.maxByOrNull { it.second }?.first
/** The Steam appid whose store name matches [name], or null for no match. */
private fun search(name: String): String? {
val term = name.replace(Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b"), " ").trim().ifEmpty { name }
val term = name.replace(EDITION, " ").trim().ifEmpty { name }
val json = get(STORE_SEARCH + URLEncoder.encode(term, "UTF-8"))?.toString(Charsets.UTF_8) ?: return null
val items = JSONObject(json).optJSONArray("items") ?: return null
val want = normalize(name)
val wantShort = normalize(term)
var best: String? = null
var bestScore = 0
for (i in 0 until items.length()) {
val item = items.getJSONObject(i)
val got = normalize(item.optString("name"))
val score = when {
got == want -> 4
got == wantShort -> 3
got.startsWith(wantShort) || wantShort.startsWith(got) -> 2
i == 0 -> 1
else -> 0
}
if (score > bestScore) { bestScore = score; best = item.optString("id") }
}
return best?.takeIf { it.isNotEmpty() && bestScore >= 1 }
return pickStoreResult(name, (0 until items.length()).map { items.getJSONObject(it) }.map { it.optString("id") to it.optString("name") })
}
private fun download(id: String, cache: File): Boolean {
@@ -149,15 +301,73 @@ object AddedGameArt {
)) {
val dst = File(cache, localName)
if (dst.isFile) continue
val bytes = get("$CDN$id/$remote") ?: continue
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
val tmp = File(cache, "$localName.tmp")
tmp.writeBytes(bytes)
if (tmp.renameTo(dst)) got = true
if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) }
}
return got
}
private fun get(url: String): ByteArray? {
/**
* Newer Steam games keep their art under hashed paths (store_item_assets/steam/apps/<id>/<hash>/…),
* where the fixed CDN names above 404. The store's item API names those paths; once per appid.
*/
private fun steamAssetUrl(id: String, remote: String): String? {
val assets = steamAssets.getOrPut(id) {
runCatching {
val input = """{"ids":[{"appid":$id}],"context":{"language":"english","country_code":"US"},"data_request":{"include_assets":true}}"""
val json = get("https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + URLEncoder.encode(input, "UTF-8"))
?.toString(Charsets.UTF_8) ?: return@runCatching emptyMap()
val a = JSONObject(json).optJSONObject("response")?.optJSONArray("store_items")?.optJSONObject(0)?.optJSONObject("assets")
?: return@runCatching emptyMap()
val format = a.optString("asset_url_format").takeIf { it.contains("\${FILENAME}") } ?: return@runCatching emptyMap()
fun url(vararg keys: String) = keys.firstNotNullOfOrNull { k -> a.optString(k).takeIf { it.isNotEmpty() } }
?.let { "https://shared.akamai.steamstatic.com/store_item_assets/" + format.replace("\${FILENAME}", it) }
mapOf(
"library_600x900.jpg" to url("library_capsule_2x", "library_capsule"),
"header.jpg" to url("header"),
"library_hero.jpg" to url("library_hero_2x", "library_hero"),
).filterValues { it != null }.mapValues { it.value!! }
}.getOrDefault(emptyMap())
}
return assets[remote]
}
private val steamAssets = java.util.concurrent.ConcurrentHashMap<String, Map<String, String>>()
/**
* When nothing else gave a cover (or header), one made from the exe's icon: the icon centred on
* its own colour, darkened toward the bottom, so the game still has a face in the list and in
* Steam. Made once per game; real art found later takes over because it is looked at first.
*/
private fun exeArt(context: Context, game: AddedGames.Game, cover: Boolean): File? {
val cache = cacheDir(context, game)
val out = File(cache, if (cover) "p-exe.png" else "header-exe.png")
if (out.isFile) return out
val icon = exeIcon(context, game) ?: return null
val src = android.graphics.BitmapFactory.decodeFile(icon.path) ?: return null
val (w, h) = if (cover) 600 to 900 else 920 to 430
val tint = android.graphics.Bitmap.createScaledBitmap(src, 1, 1, true).getPixel(0, 0)
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bmp)
fun shade(c: Int, f: Float) = android.graphics.Color.rgb(
(android.graphics.Color.red(c) * f).toInt(), (android.graphics.Color.green(c) * f).toInt(), (android.graphics.Color.blue(c) * f).toInt(),
)
canvas.drawPaint(android.graphics.Paint().apply {
shader = android.graphics.LinearGradient(0f, 0f, 0f, h.toFloat(), shade(tint, 0.7f), shade(tint, 0.15f), android.graphics.Shader.TileMode.CLAMP)
})
val side = (minOf(w, h) * 0.5f).toInt()
val left = (w - side) / 2f
val top = (h - side) / 2f
canvas.drawBitmap(src, null, android.graphics.RectF(left, top, left + side, top + side), android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG))
cache.mkdirs()
val tmp = File(cache, "${out.name}.tmp")
tmp.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
return if (tmp.renameTo(out)) out else null
}
internal fun get(url: String): ByteArray? {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 10_000
c.readTimeout = 15_000
@@ -0,0 +1,77 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.SteamLiveShortcuts
import java.io.File
/**
* The game editor's writes for an added game, Steam's non-Steam Properties in the app: name,
* target, Start in, launch options and art. Each goes through the same shortcut - the listing the
* shortcuts writer reads, and the running client - on the appid the game already has, so an edit
* never makes a second shortcut. Each call is quick (the game alone, [AddedGames.single]); the
* client and the listing follow on [AddedGamesPublisher], in order. Call off the main thread.
*/
object AddedGameEdits {
/** The added game in [folder] as it stands now, or null when it is not listed. */
fun game(context: Context, folder: String): AddedGames.Game? = AddedGames.single(context, folder)
/** The client told ([live]), then the listing rewritten, on [AddedGamesPublisher]. */
private fun publish(context: Context, live: () -> Unit) = AddedGamesPublisher.later(context, live)
/** [name] as the game's name; "" goes back to the name found by itself. */
fun setName(context: Context, folder: String, name: String): AddedGames.Game? {
SessionPrefs.setAddedGameName(context, folder, name)
return apply(context, folder)
}
/** [exe] as the game's target; false when the session cannot see it. */
fun setExe(context: Context, folder: String, exe: File): Boolean {
if (!exe.isFile || AddedGames.guestPath(context, exe) == null) return false
SessionPrefs.setAddedGameExe(context, folder, exe.path)
apply(context, folder)
return true
}
/** [dir] (a host folder) as the game's Start in; "" = the exe's own folder. False when the session cannot see it. */
fun setStartIn(context: Context, folder: String, dir: String): Boolean {
val guest = if (dir.isBlank()) "" else AddedGames.guestPath(context, File(dir.trim())) ?: return false
SessionPrefs.setAddedGameStartIn(context, folder, guest)
apply(context, folder)
return true
}
fun setLaunchOptions(context: Context, folder: String, options: String): AddedGames.Game? {
SessionPrefs.setAddedGameLaunch(context, folder, options)
return apply(context, folder)
}
/** [slot] set to [option], then handed to the shortcut. False when the image could not be had. */
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
val game = game(context, folder) ?: return false
if (!AddedGameArt.choose(context, game, slot, option)) return false
applyArt(context, game, slot)
return true
}
/** [slot] back to automatic, then handed to the shortcut. */
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
val game = game(context, folder) ?: return
AddedGameArt.reset(context, game, slot)
applyArt(context, game, slot)
}
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
publish(context) {
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
}
}
private fun apply(context: Context, folder: String): AddedGames.Game? {
val game = game(context, folder)
if (game != null) publish(context) { SteamLiveShortcuts.update(context, game) }
return game
}
}
@@ -33,26 +33,27 @@ object AddedGames {
val source: String = Library.ADDED,
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
val storeId: String? = null,
/** The exe is the picker's guess and nothing clearly won ([GameExePicker.Ranking.uncertain]). */
val exeUncertain: Boolean = false,
/** Steam's launch options for the shortcut; "" for none. */
val launchOptions: String = "",
/** Added from the Games tab (an entry of its own), not found in a Games folder. */
val single: Boolean = false,
) {
fun folderName(): String = folder.name
}
/** One Games folder and where the session sees it. */
/** A Games folder an earlier build had, and where the session sees it. */
class Root(val host: File, val guest: String)
/**
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
* the host path when two chosen folders share a name (so a folder's guest path, and with it
* every shortcut's appid, does not change when another folder is added or removed).
* The Games folders earlier builds had, bound into the session at the guest paths they had
* then (/root/Games/<name>), so the games in them (entries now, [GamesFolderMigration]) keep
* their paths and shortcuts. Nothing adds one any more.
*/
fun roots(context: Context): List<Root> {
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
return hosts.map { host ->
val name = host.name.ifEmpty { "games" }
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
Root(host, "$GUEST_DIR/$guestName")
}
GamesFolderMigration.run(context)
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP = Regex(
@@ -86,47 +87,286 @@ object AddedGames {
// root/Games/Stores): the tree is the session's / and needs no bind.
val runtime = LinuxRuntime.rootDir(context).absolutePath
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
// Internal storage, bound at /root/Storage in every session: an exe picked anywhere on it.
val storage = android.os.Environment.getExternalStorageDirectory().absolutePath
if (path.startsWith("$storage/")) return "/root/Storage/" + path.removePrefix("$storage/")
return null
}
/** The .exe files a game folder offers, best first. */
fun candidates(folder: File): List<File> {
val exes = ArrayList<File>()
val roots = listOf(folder) + (folder.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList())
for (dir in roots) {
dir.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(f.name) }
?.let { exes.addAll(it) }
}
val key = folder.name.lowercase().replace(Regex("[^a-z0-9]"), "")
return exes.sortedWith(
compareByDescending<File> { it.parentFile == folder }
.thenByDescending { it.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "").let { n -> n == key || key.startsWith(n) || n.startsWith(key) } }
.thenByDescending { it.length() },
/** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */
fun candidates(folder: File): List<File> = rank(folder).exes
/** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */
fun rank(folder: File): GameExePicker.Ranking {
val key = "${folder.path}|${folder.lastModified()}"
val now = System.currentTimeMillis()
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
val declared = declaredExes(folder)
val preferred = declared.map { it.path }.toSet()
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
declared + guessed.exes.filter { it.path !in preferred },
List(declared.size) { Int.MAX_VALUE } + guessed.exes.zip(guessed.scores)
.filter { it.first.path !in preferred }.map { it.second },
uncertain = false,
)
if (ranked.size > 256) ranked.clear()
ranked[key] = now to result
return result
}
/** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */
private val ranked = java.util.concurrent.ConcurrentHashMap<String, Pair<Long, GameExePicker.Ranking>>()
private const val RANK_FRESH_MS = 60_000L
/**
* Names identified from an exe (Steam manifest, GOG info, version resource), by the exe's path,
* size and time and its game folder's time (a GOG info file dropped in changes the latter).
*/
private val identified = java.util.concurrent.ConcurrentHashMap<String, String>()
/**
* The game's own title for [exe] when the disk names one ([GameIdentifier]: a Steam manifest,
* a GOG info file, the exe's version resource), else null (the folder name stands).
*/
internal fun identifiedName(folder: File, exe: File): String? {
val key = "${exe.path}|${exe.length()}|${exe.lastModified()}|${folder.lastModified()}"
identified[key]?.let { return it.ifEmpty { null } }
val id = runCatching { GameIdentifier.identify(exe) }.getOrNull()
val found = id?.name?.takeIf {
id.nameSource in setOf(GameIdentifier.Source.STEAM_MANIFEST_ACF, GameIdentifier.Source.GOG_INFO, GameIdentifier.Source.PE_VERSION)
}
val name = found?.let { if (id?.nameSource == GameIdentifier.Source.PE_VERSION) betterSpelling(underscoresToSpaces(it), folder.name) else it }
identified[key] = name.orEmpty()
return name
}
/**
* The exe's version resource often spells the title the way a programmer typed it ("Insane2")
* while the folder has the real spacing ("Insane 2"). When both spell the same letters and
* digits, the one with more word breaks wins; a tie keeps mixed case over all one case
* ("DiRT 3" over "dirt 3"), else the exe's spelling. Different titles keep the exe's.
*/
/** "Watch_Dogs" → "Watch Dogs": an underscore in a version-resource title is a programmer's space. */
internal fun underscoresToSpaces(name: String): String =
name.replace('_', ' ').replace(Regex("\\s+"), " ").trim().ifEmpty { name }
internal fun betterSpelling(peName: String, folderName: String): String {
fun key(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
if (key(peName) != key(folderName) || folderName.isBlank()) return peName
fun breaks(s: String) = s.count { !it.isLetterOrDigit() }
fun mixed(s: String) = s.any { it.isUpperCase() } && s.any { it.isLowerCase() }
return when {
breaks(folderName) > breaks(peName) -> folderName.trim()
breaks(folderName) < breaks(peName) -> peName
mixed(folderName) && !mixed(peName) -> folderName.trim()
else -> peName
}
}
/** The executables MicrosoftGame.config or appxmanifest.xml declares that exist inside [folder]. */
internal fun declaredExes(folder: File): List<File> {
val names = DECLARED.flatMap { (file, pattern) ->
runCatching { pattern.findAll(File(folder, file).readText()).map { it.groupValues[1] }.toList() }.getOrDefault(emptyList())
}
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
return names.asSequence()
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
.distinctBy { it.path }
.toList()
}
private val DECLARED = listOf(
"MicrosoftGame.config" to Regex("<Executable\\b[^>]*?\\bName\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
)
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
val folders = roots(context).map { it.host } + listOfNotNull(
GamesFolderMigration.run(context)
val folders = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
val removed = SessionPrefs.removedAddedGames(context).toSet()
for (dir in folders.distinctBy { it.absolutePath }) {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
// Removed from the list by the user (the folder stays on disk); restored from the settings page.
if (folder.path in removed) continue
// The store install root sits inside the Games storage; its games are scanned below, per store.
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
}
}
// Games added as one picked exe (the Games tab's +), by that exe's folder.
for (entry in AddedExes.list(context)) {
val folder = File(entry.folder)
if (folder.path in removed || !folder.isDirectory) continue
scanGame(context, folder, out, single = entry)
}
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
// An unfinished one (sidecar still `installing`, or none at all) is neither a game nor Custom.
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
return out.distinctBy { it.folder.canonicalPath }
}
/**
* The game in [folderPath] alone, built by the same rules as [scan] (store sidecar, an entry
* from the Games tab, the chosen exe, the edits) without walking every other game: what the
* editor needs for one game. Null when that folder is not a game, or was removed.
*/
fun single(context: Context, folderPath: String): Game? {
val folder = File(folderPath)
if (!folder.isDirectory || folderPath in SessionPrefs.removedAddedGames(context)) return null
val out = ArrayList<Game>(1)
scanGame(context, folder, out, single = AddedExes.list(context).firstOrNull { it.folder == folderPath })
return out.firstOrNull()
}
/**
* Stops listing the added game in [folder] at once: marked removed and dropped from the
* library cache; the running client loses its shortcut ([appId], else the one stored for the
* folder) and the listing is rewritten afterwards ([AddedGamesPublisher]). Nothing on disk is
* deleted. Quick; call off the main thread (preferences and a small file).
*/
fun remove(context: Context, folder: File, appId: Long? = null, name: String = folder.name) {
val app = context.applicationContext
val started = android.os.SystemClock.elapsedRealtime()
val id = appId ?: SessionPrefs.addedGameStoredAppId(app, folder.path)
SessionPrefs.setAddedGameRemoved(app, folder.path, true)
LibraryCache.save(app, LibraryCache.load(app).filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder.path })
Log.i(TAG, "games: remove $name took ${android.os.SystemClock.elapsedRealtime() - started}ms")
AddedGamesPublisher.later(app) { if (id != null) com.droiddeck.launcher.stores.SteamLiveShortcuts.remove(app, id) }
}
/** What [addExe] did with a picked exe. */
sealed class AddResult {
/** A new game, or the existing one the exe already is, or the listed game whose exe it now is. */
class Added(val game: Game) : AddResult()
class Existing(val game: Game) : AddResult()
class Switched(val game: Game) : AddResult()
/** The session cannot see the exe's path. */
object Unreachable : AddResult()
object NotFound : AddResult()
}
private fun canonical(f: File): String = runCatching { f.canonicalPath }.getOrDefault(f.absolutePath)
private fun inside(path: String, folder: String) = path.startsWith(folder.trimEnd('/') + "/")
/**
* The game folder [exe] belongs to, without a scan: an entry from the Games tab (Games folders
* of earlier builds included), a removed game, a store install, or the first-level folder under
* the Steam library that holds it. Null when it is in none.
*/
private fun ownerFolder(context: Context, exe: File): File? {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
// Games folders of earlier builds are entries now, found above.
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
GameStorage.effective(context)?.let { File(it.path) },
)
for (dir in dirs) {
val base = canonical(dir)
if (!inside(path, base)) continue
val first = path.removePrefix("$base/").substringBefore('/')
File(dir, first).takeIf { it.isDirectory && first != "steamapps" }?.let { return it }
}
return null
}
/**
* Adds the game [exe] is: the game itself when it is listed already, that game's exe switched
* when [exe] sits in a listed (or removed) game's folder, else a new game for [exe]'s folder.
* Only that one game is read; Steam (the running client, then the listing) follows on
* [AddedGamesPublisher]. Nothing is added when the session cannot see the path.
*/
fun addExe(context: Context, exe: File): AddResult {
val app = context.applicationContext
if (!exe.isFile) return AddResult.NotFound
val path = canonical(exe)
val owner = ownerFolder(app, exe)
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
}
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
if (owner == null) AddedExes.put(app, AddedExes.Entry(folder.path, exe.path))
val game = single(app, folder.path) ?: return AddResult.NotFound
AddedGamesPublisher.later(app) { com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, listOf(game)) { true } }
Log.i(TAG, "added ${game.name} from its exe")
return if (owner == null) AddResult.Added(game) else AddResult.Switched(game)
}
/** What [addFolder] did: the games it added, and how many of the folder's games were listed already. */
class FolderResult(val added: List<Game>, val already: Int)
/**
* "Add all games in this folder": every subfolder of [dir] with an .exe becomes a game with
* [GameExePicker]'s best one (marked uncertain where nothing clearly won), as an entry of its
* own, not a Games folder. A subfolder that is, holds or sits in a listed game counts as
* already there; a removed one comes back. Steam follows once, on [AddedGamesPublisher]. Blocking.
*/
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
val app = context.applicationContext
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
var already = 0
val fresh = ArrayList<String>()
val added = ArrayList<Game>()
for (sub in dir.listFiles { f -> f.isDirectory && !f.name.startsWith(".") }?.sortedBy { it.name.lowercase() }.orEmpty()) {
val path = canonical(sub)
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
onChecking(sub.name)
val best = rank(sub).exes.firstOrNull() ?: continue
if (guestPath(app, best) == null) continue
if (sub.path in removed) SessionPrefs.setAddedGameRemoved(app, sub.path, false)
AddedExes.put(app, AddedExes.Entry(sub.path, best.path, picked = false))
fresh.add(sub.path)
single(app, sub.path)?.let { added.add(it); onGame(it) }
}
onChecking(null)
if (fresh.isEmpty()) return FolderResult(emptyList(), already)
AddedGamesPublisher.later(app) { com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, added) { true } }
Log.i(TAG, "added ${added.size} game(s) from ${dir.name}, $already already listed")
return FolderResult(added, already)
}
/**
* The game folders already listed, without building any game: the first-level folders of the
* Steam library (as [scan] walks them), the Games tab's entries (Games folders of earlier builds
* included) and the store installs. Canonical paths; removed games are left out.
*/
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
GamesFolderMigration.run(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
for (dir in dirs.distinctBy { it.absolutePath }) {
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
if (folder.path in removed || folder.name.equals("steamapps", ignoreCase = true)) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
out.add(canonical(folder))
}
}
AddedExes.list(context).filter { it.folder !in removed }.forEach { out.add(canonical(File(it.folder))) }
StoreInstallRoot.gameFolders(context).forEach { out.add(canonical(it)) }
return out
}
/**
* The folders under a library's steamapps/common that a manifest beside it already claims.
* Steam lists those itself, so a shortcut would only add a non-Steam copy of the game. Steam
@@ -145,13 +385,16 @@ object AddedGames {
private val INSTALL_DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
/** [single]: the entry a game added from the Games tab has; its exe stands in when no other is chosen and the user picked it. */
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
run {
// A store install carries a sidecar; every one of them is a shortcut.
val sidecar = StoreGameSidecar.read(folder)
val candidates = candidates(folder)
val ranking = rank(folder)
val candidates = ranking.exes
val picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile }
// A store install says which exe it is; the folder's guess only when that file is gone.
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
// The store's launcher .bat stands in front of the exe when the game needs arguments or
@@ -159,23 +402,32 @@ object AddedGames {
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
val foundGuest = guestPath(context, launch)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
val name = sidecar?.title ?: folder.name
// A store install's title; else the title the disk gives (the shortcut's appid is kept
// per folder from its first scan, so a name found later does not move it); else the folder.
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + autoName).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
val record = config?.let { shortcutRecord(it, appId) }
val rev = record?.optInt("rev", 0) ?: 0
// Name, Start in and launch options edited in Steam's own Properties become the app's.
if (sidecar == null && record != null) adoptSteamEdits(context, folder, record)
val name = SessionPrefs.addedGameName(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() } ?: autoName
val adopted = guestPath(context, folder)
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
val exe = adopted ?: found
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
val guestDir = guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
val guestDir = SessionPrefs.addedGameStartIn(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() }
?: guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
val steamId = config?.let { steamRoute(it, appId) }
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "",
single = single != null))
}
}
@@ -208,6 +460,35 @@ object AddedGames {
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
/**
* What the user changed in Steam's Properties since the app last wrote the shortcut ([record]:
* what the app asked for, and what Steam had): a new name, launch options, or a Start in other
* than the exe's folder, taken over as the app's own. Each Steam value is taken once, so an app
* edit made after it is not undone before the shortcut is written again.
*/
internal fun adoptSteamEdits(context: Context, folder: File, record: JSONObject) {
val app = record.optJSONObject("app") ?: return
val steam = record.optJSONObject("steam") ?: return
fun adopt(field: String, value: String, apply: (String) -> Unit) {
if (SessionPrefs.addedGameAdopted(context, folder.path, field) == value) return
SessionPrefs.setAddedGameAdopted(context, folder.path, field, value)
apply(value)
}
if (steam.has("AppName") && app.has("AppName")) {
val s = steam.optString("AppName"); val a = app.optString("AppName")
if (s.isNotEmpty() && s != a) adopt("AppName", s) { SessionPrefs.setAddedGameName(context, folder.path, it) }
}
if (steam.has("LaunchOptions") && app.has("LaunchOptions")) {
val s = steam.optString("LaunchOptions"); val a = app.optString("LaunchOptions")
if (s != a) adopt("LaunchOptions", s) { SessionPrefs.setAddedGameLaunch(context, folder.path, it) }
}
if (steam.has("StartDir") && app.has("StartDir")) {
val s = unquote(steam.optString("StartDir")); val a = unquote(app.optString("StartDir"))
val exeDir = unquote(steam.optString("Exe")).substringBeforeLast('/', "")
if (s.isNotEmpty() && s != a) adopt("StartDir", s) { SessionPrefs.setAddedGameStartIn(context, folder.path, if (it == exeDir) "" else it) }
}
}
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
fun writeListing(context: Context, games: List<Game>): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
@@ -218,6 +499,7 @@ object AddedGames {
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
if (g.launchOptions.isNotEmpty()) json.append(",\"launch\":").append(quote(g.launchOptions))
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
val art = AddedGameArt.resolve(context, g)
@@ -0,0 +1,40 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import java.util.concurrent.Executors
/**
* The slow part of an added-game change, off the caller's path and in order: the running client
* told ([later]'s work), then the listing the shortcuts writer reads at the next session start and
* the library cache, both from one scan ([republish]). An add, an edit or a removal shows at once;
* this follows.
*/
object AddedGamesPublisher {
private const val TAG = "AddedGamesPublisher"
/** Tests publish at once, to read the listing right after a change. */
@VisibleForTesting
@Volatile
internal var now = false
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "added-games-publish") }
/** [live] (a DevTools call or nothing), then [republish], on the worker. */
fun later(context: Context, live: () -> Unit = {}) {
val app = context.applicationContext
val work = Runnable {
try { live() } catch (e: Exception) { Log.w(TAG, "live: ${e.message}") }
try { republish(app) } catch (e: Exception) { Log.w(TAG, "listing: ${e.message}") }
}
if (now) work.run() else worker.execute(work)
}
/** The listing and the library cache from one scan of the added games. Blocking. */
fun republish(context: Context) {
val games = AddedGames.scan(context)
AddedGames.writeListing(context, games)
LibraryCache.save(context, Library.launchableGames(context, added = games))
}
}
@@ -0,0 +1,419 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.PowerManager
import android.util.AtomicFile
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.stores.StoreGameSidecar
import kotlinx.coroutines.flow.MutableStateFlow
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.PriorityBlockingQueue
/**
* Turns on, per game, the Windows components its source's list names ([WinCompSources]). Only the
* findings marked auto are turned on, only the ones that install here, and never one the user
* switched off: the user's switches are kept apart ([WinComponents.Selection.user]) and win.
*
* All the work is done by one background thread (low priority, so are the runtime helpers it
* starts), one job at a time from one queue: working out a game's list (kept, so a page opens on
* it) and downloading a component (each once, whichever games want it). The queue waits while a
* session runs; jobs nobody asked for (the start-up sweep, a game being launched) also wait on
* battery saver, a warm device or a metered network. Nothing here runs on the caller's thread.
*/
object AutoComponents {
private const val TAG = "AutoComponents"
/** The key a game's picks are kept under: the appid its prefix is named after. */
fun appKey(game: Library.SteamGame): String? =
if (game.library == Library.ADDED) Integer.toUnsignedString(game.appId) else game.appId.takeIf { it > 0 }?.toString()
/**
* What turns on by itself for [findings], given [catalog] and what is [installed]: the catalog
* name each auto finding installs as, with its reason. A component that needs a Windows
* installer, or is not in the catalog, stays out (the editor lists it as a suggestion).
*/
fun autoPicks(findings: List<WinCompSources.Finding>, catalog: Map<String, WinComponents.Component>): Map<String, WinComponents.AutoReason> {
val out = LinkedHashMap<String, WinComponents.AutoReason>()
for (f in findings) {
if (!f.auto || f.component == null) continue
val id = WinComponents.installable(f.component, catalog)
val c = catalog[id] ?: continue
if (WinComponents.support(c, catalog) != WinComponents.Support.READY) continue
if (id !in out) out[id] = WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args)
}
return out
}
// ---- seams for tests ----
@VisibleForTesting internal var catalogOf: (Context, Boolean) -> List<WinComponents.Component>? =
{ c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
@VisibleForTesting internal var installedOf: (Context) -> Set<String> = { WinComponents.installedIds(it).toSet() }
@VisibleForTesting internal var installer: (Context, WinComponents.Component, Map<String, WinComponents.Component>, (WinComponents.Progress) -> Unit) -> String? =
{ c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
@VisibleForTesting internal var sessionRunning: () -> Boolean = { SessionState.running }
/** Battery saver, a warm device or a metered network: no work nobody asked for. */
@VisibleForTesting internal var unfavourable: (Context) -> Boolean = { c -> constrained(c) }
/** A usable network: a download waits for one. */
@VisibleForTesting internal var online: (Context) -> Boolean = { c -> isOnline(c) }
/** Tests run the queue on the caller's thread ([drain]) instead of the worker. */
@VisibleForTesting @Volatile internal var runNow = false
/** Bumped whenever a game's list or automatic components change, so an open page reads them again. */
val changes = MutableStateFlow(0)
/** The components downloading for an automatic pick right now, with their progress. */
val downloading = MutableStateFlow<Map<String, WinComponents.Progress>>(emptyMap())
/** Games (by [appKey]) whose list is queued or being worked out: a page waits on it. */
val pendingGames = MutableStateFlow<Set<String>>(emptySet())
/** Components queued for an automatic pick, not yet downloaded. */
val pendingComponents = MutableStateFlow<Set<String>>(emptySet())
private fun mark(job: Job, on: Boolean) {
if (job.pics != null) return
val flow = if (job.component != null) pendingComponents else pendingGames
val id = job.component ?: job.game?.let { appKey(it) } ?: return
flow.value = if (on) flow.value + id else flow.value - id
}
/** One piece of work: a game's list ([game]) or one component's download ([component]). [asked]: the user is waiting on it. */
internal class Job(
val game: Library.SteamGame?, val component: String?, val asked: Boolean, val seq: Long, val pics: List<Int>? = null,
/** For a game Steam installed during the running session: may run in it, unless a game is running. */
val inSession: Boolean = false,
) {
val key get() = when {
pics != null -> "pics"
component != null -> "c:$component"
else -> "g:" + (game?.let { appKey(it) } ?: "")
}
/** Needs the network: a download, or Steam asked anonymously. */
val online get() = component != null || pics != null
}
private val seq = java.util.concurrent.atomic.AtomicLong()
/** What the user asked for first, then in the order queued. */
private val jobs = PriorityBlockingQueue<Job>(16, compareByDescending<Job> { it.asked }.thenBy { it.seq })
@Volatile private var draining = false
private val queuedKeys = java.util.Collections.synchronizedSet(HashSet<String>())
/** The games waiting on a component's download, to turn it on for when it lands. */
private val waiting = HashMap<String, MutableList<Library.SteamGame>>()
@Volatile private var worker: Thread? = null
private const val PAUSE_MS = 15_000L
/**
* [game]'s list worked out again on the worker, and its components queued: [asked] when the
* user is waiting (its page is open, or they installed or added it).
*/
fun queueGame(context: Context, game: Library.SteamGame, asked: Boolean) {
if (appKey(game) == null) return
enqueue(context, Job(game, null, asked, seq.incrementAndGet()))
}
private fun queueDownload(context: Context, id: String, asked: Boolean, inSession: Boolean = false) =
enqueue(context, Job(null, id, asked, seq.incrementAndGet(), inSession = inSession))
/** Adds [job] unless the same one is queued (a game or a component, once each). */
private fun enqueue(context: Context, job: Job) {
if (!queuedKeys.add(job.key)) {
// Asked for now: ahead of what nobody asked for, so the open page does not wait behind the sweep.
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
if (runNow && !draining) drain(context)
return
}
if (job.component != null) Log.i(TAG, "wincomp: queued ${job.component}")
mark(job, true)
jobs.add(job)
if (runNow) { if (!draining) drain(context) } else ensureWorker(context.applicationContext)
}
/** Whether [job] has to wait: always while a session runs; one nobody asked for also when it is unfavourable. */
internal fun mustWait(context: Context, job: Job): Boolean =
if (job.inSession && sessionRunning()) gameRunning() || (job.online && !online(context))
else sessionRunning() || (job.online && !online(context)) || (!job.asked && unfavourable(context))
/** A game is running in the session (Steam's reaper, or Proton waiting on its game). */
@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
/**
* A game Steam installed during the running session: its list worked out and its components
* readied in the session (one job, low priority), so a launch right after finds them; it
* waits while a game runs. All other work stays paused until the session ends.
*/
fun queueInSession(context: Context, game: Library.SteamGame) {
if (appKey(game) == null) return
enqueue(context, Job(game, null, true, seq.incrementAndGet(), inSession = true))
}
/** Runs the queued jobs on this thread until the queue is empty or the next one has to wait. Tests. */
@VisibleForTesting
internal fun drain(context: Context) {
draining = true
try {
while (true) {
val job = next(context) ?: return
queuedKeys.remove(job.key)
run(context, job)
}
} finally {
draining = false
}
}
/** The first job in order that may run now, taken off the queue; null when none may. */
private fun next(context: Context): Job? = synchronized(jobs) {
val job = jobs.sortedWith(order).firstOrNull { !mustWait(context, it) } ?: return null
jobs.remove(job)
job
}
private val order = compareByDescending<Job> { it.asked }.thenBy { it.seq }
@VisibleForTesting internal fun queued(): List<String> = jobs.sortedWith(compareByDescending<Job> { it.asked }.thenBy { it.seq }).map { it.key }
@Synchronized
private fun ensureWorker(app: Context) {
if (worker?.isAlive == true) return
worker = Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
while (true) {
// Something queued, then the first that may run (one for a game installed in the
// session goes ahead of paused work); else a look again in a while.
try { jobs.put(jobs.take()) } catch (e: InterruptedException) { return@Thread }
val job = next(app)
if (job == null) {
try { Thread.sleep(PAUSE_MS) } catch (e: InterruptedException) { return@Thread }
continue
}
queuedKeys.remove(job.key)
WinComponents.background { run(app, job) }
}
}, "auto-components").apply { isDaemon = true; start() }
}
private fun run(context: Context, job: Job) {
try {
when {
job.pics != null -> SteamAppInfo.lookup(context, job.pics)
job.component != null -> download(context, job.component, job.asked, job.inSession)
else -> job.game?.let { apply(context, it, job.asked, job.inSession) }
}
} catch (e: Exception) {
Log.w(TAG, "wincomp: ${job.key}: ${e.javaClass.simpleName}: ${e.message}")
} finally {
mark(job, false)
changes.value = changes.value + 1
}
}
/**
* [game]'s list (worked out again only when what it is read from changed), its automatic
* components that are downloaded turned on, and the rest queued for download.
*/
private fun apply(context: Context, game: Library.SteamGame, asked: Boolean, inSession: Boolean = false) {
val key = appKey(game) ?: return
val folder = game.gameFiles
if (game.library == Library.ADDED && game.source == Library.ADDED && folder != null && SteamMatch.get(context, folder.path) == null) {
AddedGames.single(context, folder.path)?.let { SteamMatch.resolve(context, folder.path, it.exe, it.name) }
}
// A GOG or Epic game installed before its sidecar kept the store's list: read it once from the store.
val store = com.droiddeck.launcher.stores.Store.byId(game.source)
if (folder != null && (store == com.droiddeck.launcher.stores.Store.GOG || store == com.droiddeck.launcher.stores.Store.EPIC)) StoreListBackfill.fill(context, folder)
// Its Steam app, when the client never saw it: Steam asked anonymously now if the user is
// waiting on this game (a pass over every game asks for all of them in one go, [sweep]).
if (asked && online(context)) steamAppOf(context, game)?.let { SteamAppInfo.lookup(context, listOf(it)) }
val findings = findings(context, game)
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 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 }
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)
}
// In a session the launch waits a little for these (droiddeck-wincomponents reads the list).
if (inSession) Pending.set(context, key, missing)
val before = WinComponents.selection(context, key).auto
if (before.keys != kept.keys) {
WinComponents.setAuto(context, key, kept)
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
}
changes.value = changes.value + 1
}
/** One component downloaded and installed, then turned on for every game waiting on it. */
private fun download(context: Context, id: String, asked: Boolean, inSession: Boolean = false) {
val catalog = catalogOf(context, true)?.associateBy { it.name } ?: run { Pending.drop(context, id); return }
if (id !in installedOf(context)) {
val c = catalog[id] ?: run { Pending.drop(context, id); return }
val started = System.currentTimeMillis()
downloading.value = downloading.value + (id to WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1))
val problem = try {
installer(context, c, catalog) { p -> downloading.value = downloading.value + (id to p) }
} finally {
downloading.value = downloading.value - id
}
if (problem != null) { Log.w(TAG, "wincomp: $id did not install: $problem"); Pending.drop(context, id); return }
Log.i(TAG, "wincomp: $id installed in ${System.currentTimeMillis() - started}ms")
}
val games = synchronized(waiting) { waiting.remove(id).orEmpty() }
games.forEach { apply(context, it, asked, inSession) }
Pending.drop(context, id)
}
/**
* The components still being readied for games installed in the session, where the launch
* reads them (~/.config/droiddeck/wincomponents-pending.json in the runtime).
*/
internal object Pending {
private fun file(context: Context) = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/wincomponents-pending.json")
@Synchronized fun read(context: Context): JSONObject =
runCatching { JSONObject(file(context).readText()) }.getOrDefault(JSONObject()).let { it.optJSONObject("games") ?: JSONObject() }
@Synchronized fun set(context: Context, key: String, ids: List<String>) {
val games = read(context)
if (ids.isEmpty()) games.remove(key) else games.put(key, JSONArray(ids))
write(context, games)
}
@Synchronized fun drop(context: Context, id: String) {
val games = read(context)
for (k in games.keys().asSequence().toList()) {
val left = games.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - id
if (left.isEmpty()) games.remove(k) else games.put(k, JSONArray(left))
}
write(context, games)
}
private fun write(context: Context, games: JSONObject) {
val f = file(context)
runCatching {
f.parentFile?.mkdirs()
val tmp = File(f.path + ".tmp")
tmp.writeText(JSONObject().put("games", games).toString())
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
}
}
// ---- the list, kept per game ----
private const val FINDINGS = "wincomp-findings.json"
/** What the list is read from, by modification time: a change means it is worked out again. */
internal fun signature(context: Context, game: Library.SteamGame): String {
val folder = game.gameFiles
val parts = listOfNotNull(
folder?.lastModified(),
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
folder?.let { StoreGameSidecar.file(it).lastModified() },
folder?.let { File(it, "fuel.json").lastModified() },
SteamAppInfo.appinfoFile(context).lastModified(),
folder?.let { SteamMatch.get(context, it.path) }?.let { "${it.appId}/${it.certainty}" },
steamAppOf(context, game)?.let { SteamAppInfo.state(context, it) },
)
return parts.joinToString("|")
}
/** [game]'s list: the one kept when nothing it is read from changed, else worked out again and kept. Worker only. */
internal fun findings(context: Context, game: Library.SteamGame): List<WinCompSources.Finding> {
val key = appKey(game) ?: return emptyList()
val sig = signature(context, game)
val all = readFindings(context)
all.optJSONObject(key)?.takeIf { it.optString("sig") == sig }?.let { return decode(it.optJSONArray("findings")) }
val fresh = WinCompSources.forGame(context, game)
writeFindings(context, all.put(key, JSONObject().put("sig", sig).put("findings", encode(fresh))))
return fresh
}
/** The list kept for [appKey], without working anything out; null before the first time. */
fun cachedFindings(context: Context, appKey: String): List<WinCompSources.Finding>? =
readFindings(context).optJSONObject(appKey)?.let { decode(it.optJSONArray("findings")) }
@Synchronized
private fun readFindings(context: Context): JSONObject =
runCatching { JSONObject(AtomicFile(File(context.filesDir, FINDINGS)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
@Synchronized
private fun writeFindings(context: Context, json: JSONObject) {
val atomic = AtomicFile(File(context.filesDir, FINDINGS))
val out = atomic.startWrite()
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
}
private fun encode(list: List<WinCompSources.Finding>) = JSONArray().apply {
list.forEach { f ->
put(JSONObject().put("original", f.original).put("origin", f.origin.name).put("auto", f.auto).put("args", JSONArray(f.args))
.apply { f.component?.let { put("component", it) } })
}
}
private fun decode(arr: JSONArray?): List<WinCompSources.Finding> = if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val origin = runCatching { WinCompSources.Origin.valueOf(o.optString("origin")) }.getOrNull() ?: return@mapNotNull null
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
WinCompSources.Finding(o.optString("component").takeIf { it.isNotEmpty() }, o.optString("original"), origin, o.optBoolean("auto"), args)
}
// ---- when ----
/** Before a launch from DroidDeck: nothing but a queued look (it waits for the session to end). */
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
/** After an install or an add: the user did it, so it does not wait on a metered network. */
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
/**
* Every game, whatever its source, at app start and after each session (Steam installs its
* games without a word to DroidDeck; older GOG and Epic installs get their list read once):
* only queued, one game at a time, behind anything asked for, under the same conditions. A
* game whose files did not change costs a look at their times.
*/
fun sweep(context: Context, games: List<Library.SteamGame>) {
// First every Steam app these games are that the client never saw, asked of Steam in one
// connection; then each game's list.
val apps = games.mapNotNull { steamAppOf(context, it) }.distinct()
if (apps.isNotEmpty() && SteamAppInfo.needLookup(context, apps).isNotEmpty()) enqueue(context, Job(null, null, false, seq.incrementAndGet(), pics = apps))
games.forEach { queueGame(context, it, asked = false) }
}
/** The Steam app whose list a game takes: a Steam game's own, or an added game's match. */
private fun steamAppOf(context: Context, game: Library.SteamGame): Int? =
if (game.library != Library.ADDED) game.appId.takeIf { it > 0 }
else game.gameFiles?.let { SteamMatch.get(context, it.path) }?.appId
/** The default network is there and validated (reaches the internet). */
fun isOnline(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
/** Battery saver, thermal status moderate or worse, or a metered network. */
private fun constrained(context: Context): Boolean {
val power = context.getSystemService(PowerManager::class.java)
if (power?.isPowerSaveMode == true) return true
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
return context.getSystemService(ConnectivityManager::class.java)?.isActiveNetworkMetered == true
}
}
@@ -134,6 +134,9 @@ object DependencyDetector {
val lower = f.name.lowercase(Locale.US)
if (f.isDirectory) {
if (SKIP_DIR_RE.matches(lower)) continue
// Unity's <Game>_Data/Managed holds the game's own Mono assemblies, not a .NET
// Framework to install.
if (lower == "managed" && dir.name.endsWith("_Data", ignoreCase = true)) continue
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
@@ -157,6 +160,19 @@ object DependencyDetector {
}
}
/**
* The catalog components one installer stands for, by its path ("_CommonRedist/vcredist/2015/
* vc_redist.x64.exe", "DXSETUP.exe"): what a store's install list names. A VC++ installer
* with no year is the latest; empty for one that is none of ours.
*/
fun componentsFor(path: String): List<String> {
val p = path.replace('\\', '/').lowercase(Locale.US)
return classify(p, p.substringAfterLast('/'), isDir = false).map { if (it == VCREDIST_LOOSE) "vcredist2022" else it }
}
/** The name the catalog component [name] goes by, for a reason line. */
fun label(name: String): String = LABELS[name] ?: name
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
val loose = hits.remove(VCREDIST_LOOSE) ?: return
@@ -0,0 +1,130 @@
package com.droiddeck.launcher.frontend
import java.io.File
/**
* Which `.exe` in a game folder is the game. A real install holds installers, crash handlers,
* anti-cheat stubs and launchers beside the game, and the game itself may sit several levels down
* (`Game/Binaries/Win64/Game-Win64-Shipping.exe`), so candidates are collected with a bounded walk
* and ranked rather than guessed by position.
*/
object GameExePicker {
/** How far below a game folder to look: Unreal puts the game three down, repacks deeper. */
const val MAX_DEPTH = 5
/** Folders and .exe files looked at per game before the walk stops, so a huge game stays quick. */
const val WALK_BUDGET = 400
/** Below this the pick is marked uncertain rather than trusted silently. */
const val CONFIDENT_SCORE = 60
/** The best and the runner-up this close together: nothing clearly won. */
const val AMBIGUOUS_MARGIN = 15
/** Folders that never hold the game itself. */
private val SKIP_DIRS = setOf(
"_commonredist", "commonredist", "redist", "redistributable", "redistributables",
"directx", "dotnet", "dotnetfx", "vcredist", "vc_redist", "prerequisites", "prereq",
"installer", "installers", "__installer", "support", "extras", "docs", "documentation", "manual",
"soundtrack", "ost", "artbook", "bonus", "dlc", "mods", "saves", "savegames",
"crashreportclient", "easyanticheat", "battleye", "punkbuster", "steamworks shared", ".git",
)
private val SKIP_DIR_PREFIX = listOf("dotnet", "vcredist", "$")
private val SKIP_PATHS = listOf("engine/extras", "engine/binaries/thirdparty")
/** Exe names that are never the game. */
private val JUNK_EXE_RE = Regex(
"""(?i)^(unins\w*|setup|install\w*|vc_?redist.*|vcredist.*|dxsetup|dxwebsetup|directx.*|""" +
"""oalinst|openal.*|dotnetfx.*|ndp\d.*|unitycrashhandler\d*|crashreport\w*|crashpad\w*|""" +
"""crashsender\w*|easyanticheat\w*|eac\w*|battleye\w*|beservice\w*|punkbuster\w*|""" +
"""steamerrorreporter\d*|gameoverlayui|touchup|cleanup|config|settings|benchmark|""" +
"""activation\w*|register\w*|readme|report\w*|.*_debug|.*-debug)$""",
)
/** A folder's exes best first, with their scores, and whether the first clearly won. */
class Ranking(val exes: List<File>, val scores: List<Int>, val uncertain: Boolean)
/** Ranks the .exe files under [folder]; [keep] drops names the caller never offers. */
fun rank(folder: File, keep: (File) -> Boolean = { true }): Ranking {
val scored = collect(folder).filter { keep(it.first) }.map { (exe, depth) -> exe to score(exe, folder, depth) }
.sortedWith(compareByDescending<Pair<File, Int>> { it.second }.thenBy { it.first.path.lowercase() })
val best = scored.firstOrNull()?.second
val runnerUp = scored.getOrNull(1)?.second
val uncertain = best != null && (best < CONFIDENT_SCORE || (runnerUp != null && best - runnerUp < AMBIGUOUS_MARGIN))
return Ranking(scored.map { it.first }, scored.map { it.second }, uncertain)
}
/**
* Every plausible exe under [folder] with its depth (0 = in the folder itself): up to
* [MAX_DEPTH] folders down, nearest levels first, at most [WALK_BUDGET] entries, never through a
* symlink, past folders that never hold the game.
*/
private fun collect(folder: File): List<Pair<File, Int>> {
val found = ArrayList<Pair<File, Int>>()
var visited = 0
var level = listOf(folder)
var depth = 0
walk@ while (level.isNotEmpty()) {
val next = ArrayList<File>()
for (dir in level) {
val entries = dir.listFiles()?.sortedBy { it.name.lowercase() } ?: continue
for (f in entries) {
if (visited >= WALK_BUDGET) break@walk
if (isLink(f)) continue
if (f.isDirectory) {
visited++
if (depth < MAX_DEPTH && !skipDir(f, f.relativeTo(folder).invariantSeparatorsPath)) next.add(f)
} else if (f.isFile && f.name.endsWith(".exe", ignoreCase = true)) {
visited++
if (!JUNK_EXE_RE.matches(f.nameWithoutExtension)) found.add(f to depth)
}
}
}
level = next
depth++
}
return found
}
/**
* How likely [exe] is to be the game in [folder]. Relative only: the numbers rank candidates
* against each other and decide whether anything won clearly enough to trust.
*/
internal fun score(exe: File, folder: File, depth: Int): Int {
var score = 0
val base = exe.nameWithoutExtension
val exeKey = key(base)
val folderKey = key(folder.name)
// The exe named after its folder is the single strongest signal.
if (exeKey.isNotEmpty() && folderKey.isNotEmpty()) {
when {
exeKey == folderKey -> score += 100
folderKey.contains(exeKey) || exeKey.contains(folderKey) -> score += 60
}
}
// Unreal ships as <Game>-Win64-Shipping.exe under Binaries/Win64.
if (base.endsWith("-Win64-Shipping", true) || base.endsWith("-Win32-Shipping", true)) score += 70
val path = exe.absolutePath.lowercase()
if (path.contains("/binaries/win64") || path.contains("/binaries/win32")) score += 30
if (path.contains("/bin/") || path.contains("/bin64/")) score += 10
// A launcher is a real entry point, so it stays a candidate, just not the preferred one.
if (GameIdentifier.isLauncherExeName(base)) score -= 40
if (GameIdentifier.isJunkPeName(base)) score -= 30
// Shallower is more likely the entry point; big binaries beat small helpers.
score -= depth * 8
score += (exe.length() / (8L * 1024 * 1024)).toInt().coerceAtMost(30)
return score
}
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
val rel = relative.lowercase()
return SKIP_PATHS.any { rel == it || rel.endsWith("/$it") }
}
private fun isLink(f: File): Boolean = runCatching { java.nio.file.Files.isSymbolicLink(f.toPath()) }.getOrDefault(false)
/** Comparison key: letters and digits only, so "GTA IV" and "gta-iv" match. */
private fun key(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
}
@@ -48,7 +48,7 @@ object GameFileSync {
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
}
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
}
@@ -0,0 +1,328 @@
package com.droiddeck.launcher.frontend
import java.io.File
/**
* Best-effort identification of a game from its on-disk footprint, given its .exe: a Steam appId
* and/or a display name, so an added game is not just named after its folder or exe. Pure
* filesystem and PE parsing, no network, never throws; it reports only what the disk tells.
*
* Signal chain (appId preferred; best name chosen across all hits):
* 1. steam_appid.txt → appId (Goldberg-style installs)
* 2. ColdClientLoader / emu ini → appId (AppId= key)
* 3. appmanifest_<id>.acf → appId + name (real Steam install under steamapps/)
* 4. goggame-<id>.info → name (+ gogId) (GOG installs)
* 5. PE VS_VERSIONINFO → name (ProductName / FileDescription)
* 6. cleaned folder / exe name → name (weak fallback)
*/
object GameIdentifier {
/** How far up the directory tree to look for marker files (games nest deep, e.g. Game/Binaries/Win64/x.exe). */
private const val MAX_PARENT_DEPTH = 6
enum class Source { STEAM_APPID_TXT, STEAM_MANIFEST_ACF, STEAM_EMU_INI, GOG_INFO, PE_VERSION, FILENAME, NONE }
enum class Confidence { HIGH, MEDIUM, LOW }
/**
* @param appId Steam appId if one was found on disk, else null.
* @param gogId GOG product id if a goggame info file was found, else null.
* @param name Best display name found locally; may be null when only an appId was
* found (caller resolves the name from the appId over the network).
* @param source Which signal produced the primary identity.
*/
data class GameIdentity(
val appId: Int? = null,
val gogId: String? = null,
val name: String? = null,
val source: Source = Source.NONE,
val confidence: Confidence = Confidence.LOW,
/** Which signal gave [name]: [Source.FILENAME] means only a cleaned folder or exe name. */
val nameSource: Source = Source.NONE,
) {
val hasStrongId: Boolean get() = appId != null
}
/** Identify the game owning [exeFile]. Never throws; returns an empty identity if nothing matched. */
fun identify(exeFile: File): GameIdentity {
val dirs = ancestorDirs(exeFile)
// Steam appId from a bare steam_appid.txt (in the exe dir, a steam_settings/ subdir, or an ancestor).
val steamAppId = dirs.firstNotNullOfOrNull { readSteamAppIdTxt(it) }
// AppId= out of a ColdClientLoader.ini / steam_emu.ini next to the game.
val emuAppId = dirs.firstNotNullOfOrNull { readEmuIniAppId(it) }
// Real Steam install: appmanifest_<id>.acf up under steamapps/ — carries both appId and name.
val acf = resolveSteamManifest(exeFile, dirs)
// GOG: goggame-<id>.info JSON with the real title.
val gog = dirs.firstNotNullOfOrNull { readGogInfo(it) }
// PE ProductName / FileDescription — but not when it merely names a launcher/loader
// ("Rockstar Games Launcher Redirector", "GSE", …), which must not become the game name.
val peName = PeVersionInfo.bestName(PeVersionInfo.read(exeFile))?.takeUnless { isJunkPeName(it) }
val appId = acf?.appId ?: steamAppId ?: emuAppId
// Best name, most-trustworthy first.
val nameHit: Pair<String, Source>? = when {
acf?.name != null -> acf.name to Source.STEAM_MANIFEST_ACF
gog?.name != null -> gog.name to Source.GOG_INFO
peName != null -> peName to Source.PE_VERSION
else -> cleanedFallbackName(exeFile)?.let { it to Source.FILENAME }
}
// Primary source = whatever gave us the appId (strongest), else the name source.
val primary: Source = when {
acf?.appId != null -> Source.STEAM_MANIFEST_ACF
steamAppId != null -> Source.STEAM_APPID_TXT
emuAppId != null -> Source.STEAM_EMU_INI
else -> nameHit?.second ?: Source.NONE
}
val confidence = when {
appId != null -> Confidence.HIGH
nameHit?.second == Source.GOG_INFO -> Confidence.HIGH
nameHit?.second == Source.PE_VERSION -> Confidence.MEDIUM
else -> Confidence.LOW
}
return GameIdentity(
appId = appId,
gogId = gog?.id,
name = nameHit?.first?.let(::normalizeName)?.takeIf { it.isNotBlank() },
source = primary,
confidence = confidence,
nameSource = nameHit?.second ?: Source.NONE,
)
}
/**
* Make an identified title readable and filesystem-safe (a Steam shortcut's name also names
* files): "DARK SOULS: REMASTERED" becomes "DARK SOULS - REMASTERED". Strips trademark marks,
* turns title punctuation into safe equivalents, and collapses whitespace.
*/
fun normalizeName(raw: String): String {
var s = raw
s = s.replace("™", "").replace("®", "").replace("©", "")
s = s.replace(Regex("""(?i)\(\s*(tm|r|c)\s*\)"""), "") // "(TM)" / "(R)" / "(C)"
s = s.replace(":", " - ") // colon → dash separator (safe + readable)
s = s.replace(Regex("""[\\/]"""), " ") // path separators → space
s = s.replace(Regex("""["*?<>|]"""), "") // other illegal chars → drop
// Drop trailing generic descriptors a PE FileDescription tacks on ("DiRT 3 Executable" → "DiRT 3").
s = s.replace(Regex("""(?i)[ \-]+(executable|application|launcher|redistributable|installer|setup)$"""), "")
s = s.replace(Regex("""\s+"""), " ").trim() // collapse whitespace
return s.trim(' ', '-').trim() // tidy stray leading/trailing dashes
}
// ── directory walk ──
/** The exe's directory plus its ancestors, nearest-first, bounded by [MAX_PARENT_DEPTH]. */
private fun ancestorDirs(exeFile: File): List<File> {
val out = ArrayList<File>(MAX_PARENT_DEPTH + 1)
var d: File? = exeFile.absoluteFile.parentFile
var depth = 0
while (d != null && depth <= MAX_PARENT_DEPTH) {
out.add(d)
d = d.parentFile
depth++
}
return out
}
// ── signal readers (all best-effort; return null on anything unexpected) ──
/** steam_appid.txt directly in [dir] or in a steam_settings/ subdir. */
private fun readSteamAppIdTxt(dir: File): Int? {
for (candidate in arrayOf(File(dir, "steam_appid.txt"), File(dir, "steam_settings/steam_appid.txt"))) {
// Strip a leading UTF-8 BOM (trim() doesn't) before reading the digits.
val id = readSmallTextFile(candidate)?.removePrefix("\uFEFF")?.trim()
?.takeWhile { it.isDigit() }?.toIntOrNull()
if (id != null && id > 0) return id
}
return null
}
/**
* First `AppId=<n>` in a crack/emu config .ini next to the game (ColdClientLoader, steam_emu,
* flt, hlm, …). Known names are tried first, then any other `.ini` in the dir — only crack
* configs carry an `AppId=` key, game settings .ini files (settings.ini, graphics.ini) don't,
* so the broad scan is safe.
*/
private fun readEmuIniAppId(dir: File): Int? {
val re = Regex("""(?im)^\s*appid\s*=\s*(\d+)""")
val named = arrayOf(
"ColdClientLoader.ini", "steam_emu.ini", "flt.ini", "hlm.ini",
"valve.ini", "cream_api.ini", "SmartSteamEmu.ini", "GreenLuma.ini",
)
val seen = HashSet<String>()
val candidates = ArrayList<File>()
for (n in named) File(dir, n).let { if (it.isFile) { candidates.add(it); seen.add(n.lowercase()) } }
(dir.listFiles { f -> f.isFile && f.name.endsWith(".ini", ignoreCase = true) && f.name.lowercase() !in seen }
?: emptyArray()).sortedBy { it.name }.forEach { candidates.add(it) }
for (f in candidates) {
val text = readSmallTextFile(f) ?: continue
val id = re.find(text)?.groupValues?.getOrNull(1)?.toIntOrNull()
if (id != null && id > 0) return id
}
return null
}
private data class Acf(val appId: Int?, val name: String?)
/**
* Resolve the Steam appmanifest for this game: find the `common` ancestor on the exe's
* path, read the sibling `steamapps/appmanifest_*.acf`, and match by installdir == the
* game folder under common. No match, no manifest: a folder in steamapps/common that no
* manifest claims is exactly the kind added games list, and the library's one other game is
* not its name.
*/
private fun resolveSteamManifest(exeFile: File, dirs: List<File>): Acf? {
val commonIdx = dirs.indexOfFirst { it.name.equals("common", ignoreCase = true) }
if (commonIdx < 0) return null
val commonDir = dirs[commonIdx]
val steamapps = commonDir.parentFile ?: return null
val manifests = steamapps.listFiles { f ->
f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf")
} ?: return null
if (manifests.isEmpty()) return null
// The game's install folder is the child of `common` on our descent path.
val gameFolder = if (commonIdx > 0) dirs[commonIdx - 1].name else null
val match = manifests.firstOrNull { m ->
val text = readSmallTextFile(m) ?: return@firstOrNull false
val installdir = vdfValue(text, "installdir")
gameFolder != null && installdir != null && installdir.equals(gameFolder, ignoreCase = true)
} ?: return null
val text = readSmallTextFile(match) ?: return null
val appId = vdfValue(text, "appid")?.toIntOrNull()?.takeIf { it > 0 }
val name = vdfValue(text, "name")?.takeIf { it.isNotBlank() }
return if (appId == null && name == null) null else Acf(appId, name)
}
private data class Gog(val id: String?, val name: String?)
/** goggame-<id>.info JSON: pull `name` (and the id from the filename). */
private fun readGogInfo(dir: File): Gog? {
val files = dir.listFiles { f ->
f.isFile && f.name.startsWith("goggame-") && f.name.endsWith(".info")
} ?: return null
for (f in files) {
val text = readSmallTextFile(f) ?: continue
val name = jsonStringValue(text, "name")?.takeIf { it.isNotBlank() } ?: continue
val id = f.name.removePrefix("goggame-").removeSuffix(".info").takeIf { it.isNotBlank() }
return Gog(id, name)
}
return null
}
// ── filename fallback ──
private val REPACK_TOKENS = setOf(
"fitgirl", "dodi", "codex", "plaza", "skidrow", "rune", "empress", "flt", "tenoke",
"reloaded", "elamigos", "razor1911", "hoodlum", "gog", "repack", "multi", "goldberg",
// repack/site tags that show up in folder names
"ankergames", "steamrip", "onlinefix", "online-fix", "gload", "fitgirlrepacks",
)
private const val EXE_NOISE_WORDS =
"""win64|win32|shipping|wingdk|client|launcher|game|x64|x86|64bit|32bit"""
/**
* Does this exe basename look like engine/arch boilerplate rather than a title? Deliberately
* UNANCHORED — "GameConTest" is boilerplate-ish wherever the giveaway sits, and the answer
* decides whether we trust the folder name instead of the exe name.
*/
private val EXE_NOISE_RE = Regex("""(?i)[-_. ]*(?:$EXE_NOISE_WORDS)""")
/**
* Trailing noise to strip off a title. ANCHORED to the end and allowed to repeat, because these
* words are only junk as a suffix: "Witcher3-Win64-Shipping" -> "Witcher3", while
* "Game of Thrones" and "Client Simulator" must survive intact.
*
* Kept separate from [EXE_NOISE_RE] on purpose. One regex used to serve both jobs, so
* anchoring it to fix stripping ALSO narrowed the boilerplate test — and a game in
* "Game Controller Test/GameConTest.exe" stopped preferring its folder and became "GameConTest".
*/
private val EXE_SUFFIX_RE = Regex("""(?i)(?:[-_. ]*(?:$EXE_NOISE_WORDS))+$""")
private val VERSION_TOKEN_RE = Regex("""(?i)\b(v?\d+([._]\d+)+|build[-_ ]?\d+|update[-_ ]?\d+)\b""")
/**
* True when an exe basename is a launcher/loader rather than the game — its parent folder is the
* better title. Catches "PlayGTAV"/"Play - GTA IV"/"LaunchGame"/"MyLauncher"/"GSE" but NOT a real
* title that merely starts with those letters ("PlayerUnknown"). A "play|launch|start|run" prefix
* counts only when it's a whole token — followed by a separator or a camelCase uppercase boundary,
* not another lowercase letter (checked case-sensitively, so it survives the case-insensitive JUNK
* matching used elsewhere).
*/
/** True when an exe basename looks like a launcher/loader rather than the game itself. */
fun isLauncherExeName(base: String): Boolean {
val lower = base.lowercase()
if (lower == "gse" || lower.endsWith("launcher") || lower.endsWith("redirector")) return true
for (p in arrayOf("play", "launch", "start", "run")) {
if (!lower.startsWith(p)) continue
val rest = base.substring(p.length)
if (rest.isEmpty()) return true // exactly "Play"
val c = rest[0]
if (!c.isLetterOrDigit() || c.isUpperCase()) return true // "Play - GTA IV" / camelCase "PlayGTAV"
// lowercase continuation ("Player…") → part of a longer word, not a launcher
}
return false
}
/** A PE ProductName/FileDescription that describes a launcher/loader, not the game — reject it so it
* doesn't become the shortcut name (e.g. "Rockstar Games Launcher Redirector", "GSE", "Steam"). */
private val JUNK_NAME_RE = Regex(
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
)
/** True when a PE name is a launcher/loader label rather than a real game title. */
fun isJunkPeName(name: String): Boolean {
val n = name.trim()
return n.isEmpty() || JUNK_NAME_RE.matches(n)
}
/**
* Prefer the game's install-folder name (usually the real title) over the exe basename when the
* exe is a UE-style shipping binary OR a launcher/loader; then strip repack/suffix noise.
*/
private fun cleanedFallbackName(exeFile: File): String? {
val exeBase = exeFile.nameWithoutExtension
val preferFolder = EXE_NOISE_RE.containsMatchIn(exeBase) ||
isLauncherExeName(exeBase) ||
exeBase.equals("game", true) || exeBase.equals("start", true)
val folder = exeFile.absoluteFile.parentFile?.name
val raw = if (preferFolder && !folder.isNullOrBlank()) folder else exeBase
return cleanName(raw)
}
/** Strip bracketed tags, repack-group names, engine/arch suffixes and version tokens; tidy separators. */
private fun cleanName(raw: String): String? {
var s = raw
s = s.replace(Regex("""[\[(][^\])]*[\])]"""), " ") // (…) […]
s = VERSION_TOKEN_RE.replace(s, " ")
s = EXE_SUFFIX_RE.replace(s, " ")
s = s.replace(Regex("""[._]+"""), " ")
// Drop standalone repack-group words.
s = s.split(Regex("""[\s-]+"""))
.filter { it.isNotBlank() && it.lowercase() !in REPACK_TOKENS }
.joinToString(" ")
s = s.trim()
return s.ifBlank { null }
}
// ── tiny text/format helpers (regex-based → no Android/org.json dependency, unit-testable) ──
/** Read a small text file (guards against accidentally slurping a huge file). */
private fun readSmallTextFile(f: File, maxBytes: Long = 1L * 1024 * 1024): String? = try {
if (f.isFile && f.length() in 1..maxBytes) f.readText(Charsets.UTF_8) else null
} catch (_: Throwable) {
null
}
/** Value of a Valve KeyValues (.acf/.vdf) `"key" "value"` pair, first match, case-insensitive key. */
private fun vdfValue(text: String, key: String): String? =
Regex(""""${Regex.escape(key)}"\s*"([^"]*)"""", RegexOption.IGNORE_CASE).find(text)?.groupValues?.getOrNull(1)?.trim()
/** Value of a JSON `"key": "value"` pair, first match. Sufficient for the flat goggame .info files. */
private fun jsonStringValue(text: String, key: String): String? =
Regex(""""${Regex.escape(key)}"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"""").find(text)
?.groupValues?.getOrNull(1)?.replace("\\\"", "\"")?.replace("\\\\", "\\")?.trim()
}
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
import java.util.zip.CRC32
/**
* Games folders (the roots earlier builds let the user add in Steam settings) become what the
* Games tab's "Add all games in this folder" makes: one entry per game ([AddedExes]). Everything
* a game had is keyed by its own folder and stays as it was - the chosen exe, the shortcut appid,
* the name, Start in, launch options, art choices, a removal. The roots stay bound into the
* session at the same guest paths ([SessionPrefs.gameFolderBinds]), so every exe keeps its path
* inside the session and every Steam shortcut its target; they are no longer Steam libraries.
* Runs whenever saved roots remain (so twice is the same as once) and clears them after.
*/
object GamesFolderMigration {
private const val TAG = "GamesFolderMigration"
@Synchronized
fun run(context: Context) {
val dirs = SessionPrefs.addedGamesDirs(context)
if (dirs.isEmpty()) return
val roots = legacyRoots(SessionPrefs.gameFolderBinds(context).map { it.first } + dirs)
.filter { root -> root.host.path in dirs }
// The binds first: a game's guest path (and with it the shortcut's appid) depends on them.
SessionPrefs.setGameFolderBinds(context, SessionPrefs.gameFolderBinds(context) + roots.map { it.host.path to it.guest })
val removed = SessionPrefs.removedAddedGames(context).toSet()
val entries = AddedExes.list(context).map { it.folder }.toMutableSet()
val storeRoots = StoreInstallRoot.roots(context).map { it.absolutePath }
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 (folder.name.equals("steamapps", ignoreCase = true) || 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++
}
Log.i(TAG, "games: migrated ${root.host} → $n games")
}
SessionPrefs.setAddedGamesDirs(context, emptyList())
SessionPrefs.setGamesFoldersMigrated(context)
}
/**
* The guest paths earlier builds gave Games folders: /root/Games/<folder name>, with a short
* hash of the host path when two share a name. Kept exactly, so nothing moves in the session.
*/
internal fun legacyRoots(paths: List<String>): List<AddedGames.Root> {
val hosts = paths.distinct().map { File(it) }
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
return hosts.map { host ->
val name = host.name.ifEmpty { "games" }
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
AddedGames.Root(host, "${AddedGames.GUEST_DIR}/$guestName")
}
}
}
@@ -152,21 +152,24 @@ object Library {
}
/** The same installed-game inventory used for links, shortcuts and file exports. */
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
/** [added]: the added games when the caller has just scanned them, so they are not scanned again. */
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
)
}
}).distinctBy { it.gameId }
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
}
/** One added game as the Games tab lists it; [roots] the Steam libraries, for its Proton prefix. */
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
)
}
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
val appDir = File(cache, appId.toString())
@@ -0,0 +1,250 @@
package com.droiddeck.launcher.frontend
import java.io.File
import java.io.RandomAccessFile
/**
* Reads the StringFileInfo strings (ProductName, FileDescription, …) out of the
* VS_VERSION_INFO resource embedded in a Windows PE executable (.exe).
*
* Walks the PE resource directory to the first RT_VERSION resource — the same
* navigation [com.droiddeck.launcher.files.PeIconExtractor] uses for icons — then parses the alignment-heavy
* VS_VERSIONINFO → StringFileInfo → StringTable → String tree by hand.
*
* Everything is best-effort: any malformed / oversized / unsupported input returns
* null (or an empty map). No allocation beyond the single resource blob.
*/
object PeVersionInfo {
private const val RT_VERSION = 16
private const val MAX_RES_SECTION = 64 * 1024 * 1024 // don't slurp absurd resource sections
/**
* Returns the StringFileInfo key→value map (e.g. `"ProductName" -> "Palworld"`),
* or null if the file has no readable version resource. Keys are the raw
* VS_VERSIONINFO names; look up [productName]/[fileDescription] for the common ones.
*/
fun read(file: File): Map<String, String>? = try {
RandomAccessFile(file, "r").use { raf ->
val blob = versionResourceBytes(raf) ?: return@use null
parseStringFileInfo(blob).takeIf { it.isNotEmpty() }
}
} catch (_: Throwable) {
null
}
/**
* Best display name from a version map. ProductName is usually the cleanest title, but is
* sometimes an internal abbreviation (e.g. God of War ships ProductName "GoW" while
* FileDescription is "God of War"). When ProductName is a single spaceless token and
* FileDescription is a normal multi-word title, prefer FileDescription. Trimmed; null if
* neither is meaningful.
*/
fun bestName(map: Map<String, String>?): String? {
if (map == null) return null
val product = map["ProductName"]?.trim()?.takeIf { it.isNotEmpty() }
val desc = map["FileDescription"]?.trim()?.takeIf { it.isNotEmpty() }
if (product != null && desc != null &&
!product.contains(' ') && desc.contains(' ') && desc.length <= 60
) {
return desc
}
return product ?: desc
}
// ── locate the RT_VERSION resource blob (mirrors PeIconExtractor's navigation) ──
private fun versionResourceBytes(raf: RandomAccessFile): ByteArray? {
val dos = read(raf, 0, 0x40) ?: return null
if (u8(dos, 0) != 0x4D || u8(dos, 1) != 0x5A) return null // "MZ"
val peOff = u32(dos, 0x3C).toInt()
val coff = read(raf, peOff.toLong(), 24) ?: return null
if (u8(coff, 0) != 0x50 || u8(coff, 1) != 0x45 || u8(coff, 2) != 0 || u8(coff, 3) != 0) return null // "PE\0\0"
val numSections = u16(coff, 6)
val sizeOpt = u16(coff, 20)
if (numSections <= 0 || numSections > 96 || sizeOpt <= 0) return null
val optOff = peOff + 24L
val opt = read(raf, optOff, sizeOpt) ?: return null
val magic = u16(opt, 0)
val ddOff = if (magic == 0x20B) 112 else 96 // PE32+ vs PE32 data-directory offset
val resDirEntry = ddOff + 2 * 8 // data directory index 2 = resource table
if (resDirEntry + 8 > opt.size) return null
val resRVA = u32(opt, resDirEntry).toInt()
val resSize = u32(opt, resDirEntry + 4).toInt()
if (resRVA == 0 || resSize == 0) return null
val secTabOff = optOff + sizeOpt
val secTab = read(raf, secTabOff, numSections * 40) ?: return null
var secVA = 0
var secPtr = 0L
var secRaw = 0
var found = false
for (i in 0 until numSections) {
val o = i * 40
if (o + 40 > secTab.size) break
val va = u32(secTab, o + 12).toInt()
val vSize = u32(secTab, o + 8).toInt()
val raw = u32(secTab, o + 16).toInt()
val pRaw = u32(secTab, o + 20)
val span = maxOf(vSize, raw)
if (resRVA >= va && resRVA < va + span) {
secVA = va; secPtr = pRaw; secRaw = raw; found = true; break
}
}
if (!found || secRaw <= 0 || secRaw > MAX_RES_SECTION) return null
val res = read(raf, secPtr, secRaw) ?: return null
val resDirStart = resRVA - secVA
if (resDirStart < 0 || resDirStart + 16 > res.size) return null
val verTypeDir = findIdSubdir(res, resDirStart, RT_VERSION, resDirStart) ?: return null
val verData = firstDataEntry(res, verTypeDir, resDirStart) ?: return null
return dataEntryBytes(res, verData, secVA)
}
/** Resolve the sub-directory under [dirIdx] whose id matches [wantId]; null if absent or not a directory. */
private fun findIdSubdir(res: ByteArray, dirIdx: Int, wantId: Int, resDirStart: Int): Int? {
if (dirIdx + 16 > res.size) return null
val total = u16(res, dirIdx + 12) + u16(res, dirIdx + 14)
val start = dirIdx + 16
for (i in 0 until total) {
val e = start + i * 8
if (e + 8 > res.size) break
val id = u32(res, e)
val off = u32(res, e + 4)
if (id and 0x80000000L == 0L && id.toInt() == wantId) {
if (off and 0x80000000L != 0L) return resDirStart + (off and 0x7FFFFFFFL).toInt()
return null
}
}
return null
}
/** Descend the first entry of each level until an IMAGE_RESOURCE_DATA_ENTRY; returns its index. */
private fun firstDataEntry(res: ByteArray, dirIdx: Int, resDirStart: Int): Int? {
if (dirIdx + 16 > res.size) return null
if (u16(res, dirIdx + 12) + u16(res, dirIdx + 14) == 0) return null
val e = dirIdx + 16
if (e + 8 > res.size) return null
val off = u32(res, e + 4)
return if (off and 0x80000000L != 0L) {
firstDataEntry(res, resDirStart + (off and 0x7FFFFFFFL).toInt(), resDirStart)
} else {
resDirStart + off.toInt()
}
}
/** Read the bytes an IMAGE_RESOURCE_DATA_ENTRY points at (its OffsetToData is an RVA). */
private fun dataEntryBytes(res: ByteArray, entryIdx: Int, secVA: Int): ByteArray? {
if (entryIdx < 0 || entryIdx + 8 > res.size) return null
val rva = u32(res, entryIdx).toInt()
val size = u32(res, entryIdx + 4).toInt()
val start = rva - secVA
if (size <= 0 || start < 0 || start + size > res.size) return null
return res.copyOfRange(start, start + size)
}
// ── VS_VERSIONINFO tree parse ──
//
// Every node is: WORD wLength, WORD wValueLength, WORD wType, WCHAR szKey[],
// then (32-bit aligned) an optional Value, then (32-bit aligned) child nodes.
// We only care about: VS_VERSIONINFO → StringFileInfo → StringTable → String.
private fun parseStringFileInfo(b: ByteArray): Map<String, String> {
val out = LinkedHashMap<String, String>()
if (b.size < 6) return out
val rootLen = minOf(u16(b, 0), b.size)
// VS_VERSION_INFO header: skip szKey "VS_VERSION_INFO" then the FIXEDFILEINFO value.
val rootValLen = u16(b, 2)
var pos = align4(6 + (utf16zLen(b, 6) + 1) * 2)
pos = align4(pos + rootValLen)
while (pos + 6 <= rootLen) {
val childLen = u16(b, pos)
if (childLen < 6 || pos + childLen > b.size) break
val key = utf16z(b, pos + 6)
if (key == "StringFileInfo") parseTables(b, pos, pos + childLen, out)
pos = align4(pos + childLen)
if (childLen == 0) break
}
return out
}
/** Iterate the StringTable children inside a StringFileInfo block [start, end). */
private fun parseTables(b: ByteArray, start: Int, end: Int, out: MutableMap<String, String>) {
var p = align4(start + 6 + (utf16zLen(b, start + 6) + 1) * 2)
while (p + 6 <= end) {
val tableLen = u16(b, p)
if (tableLen < 6 || p + tableLen > end) break
parseStrings(b, p, minOf(p + tableLen, end), out)
p = align4(p + tableLen)
if (tableLen == 0) break
}
}
/** Iterate the String children inside a StringTable block [start, end). */
private fun parseStrings(b: ByteArray, start: Int, end: Int, out: MutableMap<String, String>) {
var q = align4(start + 6 + (utf16zLen(b, start + 6) + 1) * 2)
while (q + 6 <= end) {
val strLen = u16(b, q)
if (strLen < 6 || q + strLen > end) break
val key = utf16z(b, q + 6)
val valOff = align4(q + 6 + (key.length + 1) * 2)
if (key.isNotEmpty() && valOff < q + strLen) {
val value = utf16z(b, valOff, q + strLen).trim()
if (value.isNotEmpty()) out[key] = value
}
q = align4(q + strLen)
if (strLen == 0) break
}
}
// ── little-endian / UTF-16 readers ──
private fun align4(x: Int): Int = (x + 3) and 3.inv()
/** Number of UTF-16 code units before the null terminator starting at [off]. */
private fun utf16zLen(b: ByteArray, off: Int): Int {
var i = off
while (i + 1 < b.size) {
if (b[i].toInt() == 0 && b[i + 1].toInt() == 0) break
i += 2
}
return (i - off) / 2
}
/** Read a null-terminated UTF-16LE string starting at [off], not reading past [limit]. */
private fun utf16z(b: ByteArray, off: Int, limit: Int = b.size): String {
val sb = StringBuilder()
var i = off
val end = minOf(limit, b.size)
while (i + 1 < end) {
val c = u8(b, i) or (u8(b, i + 1) shl 8)
if (c == 0) break
sb.append(c.toChar())
i += 2
}
return sb.toString()
}
private fun u8(b: ByteArray, off: Int): Int = if (off < 0 || off >= b.size) 0 else b[off].toInt() and 0xFF
private fun u16(b: ByteArray, off: Int): Int = u8(b, off) or (u8(b, off + 1) shl 8)
private fun u32(b: ByteArray, off: Int): Long =
u8(b, off).toLong() or (u8(b, off + 1).toLong() shl 8) or
(u8(b, off + 2).toLong() shl 16) or (u8(b, off + 3).toLong() shl 24)
private fun read(raf: RandomAccessFile, pos: Long, len: Int): ByteArray? {
if (len <= 0 || pos < 0) return null
val fileLen = raf.length()
if (pos >= fileLen) return null
val n = minOf(len.toLong(), fileLen - pos).toInt()
if (n <= 0) return null
val buf = ByteArray(n)
raf.seek(pos)
raf.readFully(buf)
return buf
}
}
@@ -0,0 +1,95 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionState
import java.io.File
/**
* A Steam game whose download finishes during a session, noticed by its appmanifest (a new one,
* or StateFlags turning to 4, fully installed) with a light look every five seconds while the
* session runs, and handed to [AutoComponents] as a single job that may run in the session, so its
* Windows components are in place when the user plays it right after the download.
*/
object SessionInstalls {
private const val TAG = "SessionInstalls"
private const val POLL_MS = 5_000L
/** One appmanifest as read: the app, its name and folder, whether it is fully installed. */
class Manifest(val appId: Int, val name: String, val installDir: String, val installed: Boolean, val steamapps: File)
private val APPID = Regex("\"appid\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
private val NAME = Regex("\"name\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
private val DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
private val STATE = Regex("\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
internal fun parse(file: File): Manifest? = runCatching {
val text = file.readText()
val id = APPID.find(text)?.groupValues?.get(1)?.toInt() ?: return null
Manifest(id, NAME.find(text)?.groupValues?.get(1).orEmpty(), DIR.find(text)?.groupValues?.get(1).orEmpty(),
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
}.getOrNull()
/** The libraries the session's client installs to, as host folders. */
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
).filter { it.isDirectory }
/**
* Keeps what the libraries hold between looks: a manifest re-read only when its time changed.
* [poll] returns the apps that became fully installed since the last look (none on the first).
*/
internal class Watch(private val dirs: () -> List<File>) {
private val seen = HashMap<String, Pair<Long, Manifest?>>()
private var first = true
fun poll(): List<Manifest> {
val fresh = ArrayList<Manifest>()
for (dir in dirs()) for (f in dir.listFiles { x -> x.isFile && x.name.startsWith("appmanifest_") && x.name.endsWith(".acf") }.orEmpty()) {
val before = seen[f.path]
if (before != null && before.first == f.lastModified()) continue
val now = parse(f)
seen[f.path] = f.lastModified() to now
if (!first && now != null && now.installed && before?.second?.installed != true) fresh.add(now)
}
first = false
return fresh
}
}
/** A finished install as the Games tab would list it. */
internal fun gameOf(m: Manifest): Library.SteamGame =
Library.SteamGame(m.appId, m.name.ifEmpty { m.appId.toString() }, null, m.steamapps.parentFile?.path ?: "steam",
gameFiles = File(m.steamapps, "common/${m.installDir}"), source = Library.SOURCE_STEAM)
/**
* Looks every five seconds while the session runs, on a thread of its own at background
* priority; each finished install becomes one job for its Windows components.
*/
fun watch(context: Context) {
val app = context.applicationContext
Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
val watch = Watch { steamappsDirs(app) }
try {
// The session is still starting when this is called: up to two minutes for it.
var waited = 0L
while (!SessionState.running && waited < 120_000L) { Thread.sleep(POLL_MS); waited += POLL_MS }
watch.poll()
while (SessionState.running) {
for (m in watch.poll()) {
Log.i(TAG, "wincomp: ${m.name} (${m.appId}) finished installing in the session")
AutoComponents.queueInSession(app, gameOf(m))
}
Thread.sleep(POLL_MS)
}
} catch (e: InterruptedException) {
} catch (e: Exception) {
Log.w(TAG, "install watch: ${e.message}")
}
}, "session-installs").apply { isDaemon = true }.start()
}
}
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.DataInputStream
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Steam's own data for an app, from the client in the runtime: its app-info cache
* (Steam/appcache/appinfo.vdf, the PICS data the client keeps for every app it has seen). What is
* read is which Steamworks Shared depots (the VC++, DirectX... redistributables of app 228980) the
* app's depots point at, and its name. Kept per appid in the app's files, so it works offline; an
* app the client has not seen is looked for again after a week.
*/
object SteamAppInfo {
private const val TAG = "SteamAppInfo"
private const val CACHE = "steam-appinfo.json"
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
/** The Steamworks Shared app the redistributable depots belong to. */
const val SHARED_APP = "228980"
class Info(val name: String, val sharedDepots: List<String>)
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
/** The shared depots for [appId]: from the cache, else the client's app-info file; null when unknown. */
@Synchronized
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
val cacheFile = AtomicFile(File(context.filesDir, CACHE))
val cache = runCatching { JSONObject(cacheFile.readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
val now = System.currentTimeMillis()
val mtime = appinfo.lastModified()
val known = cache.optJSONObject(appId.toString())
known?.let { o ->
if (!o.optBoolean("miss")) return Info(o.optString("name"), o.optJSONArray("depots").strings())
// A miss is read again when the client has written its cache since (the user may have
// looked at the app in Steam), else a week later.
if (now - o.optLong("at") < RETRY_AFTER_MS && o.optLong("mtime") == mtime) return null
}
val read = runCatching { read(appinfo, appId) }.onFailure { Log.w(TAG, "appinfo: ${it.javaClass.simpleName}") }.getOrNull()
val entry = JSONObject().put("at", now).put("mtime", mtime)
// When Steam was last asked anonymously ([lookup]), kept across re-reads of the file.
known?.optLong("picsAt")?.takeIf { it > 0 }?.let { entry.put("picsAt", it) }
if (read == null) entry.put("miss", true) else entry.put("name", read.name).put("depots", JSONArray(read.sharedDepots))
cache.put(appId.toString(), entry)
val out = cacheFile.startWrite()
try { out.write(cache.toString().toByteArray()); cacheFile.finishWrite(out) } catch (e: Exception) { cacheFile.failWrite(out) }
return read
}
/** Steam asked anonymously ([SteamPics]); a test stands in for it. */
@androidx.annotation.VisibleForTesting
internal var pics: (List<Int>) -> Map<Int, Info?>? = { SteamPics.lookup(it) }
/**
* Of [appIds], those neither the client's cache (appinfo.vdf, read first: free) nor an earlier
* answer has, and that Steam was not asked about anonymously in the past week.
*/
fun needLookup(context: Context, appIds: List<Int>): List<Int> {
val wanted = appIds.distinct().filter { it > 0 && info(context, it) == null }
if (wanted.isEmpty()) return emptyList()
val cache = cache(context)
val now = System.currentTimeMillis()
return wanted.filter { now - (cache.optJSONObject(it.toString())?.optLong("picsAt") ?: 0L) >= RETRY_AFTER_MS }
}
/**
* [appIds] the client never saw, asked of Steam anonymously in one connection ([SteamPics], in
* batches) and every answer kept like the client's: an app's depots and name, or a miss tried
* again in a week. False when nothing needed asking or Steam could not be reached. Blocking.
*/
@Synchronized
fun lookup(context: Context, appIds: List<Int>): Boolean {
val need = needLookup(context, appIds)
if (need.isEmpty()) return false
val answers = pics(need) ?: return false
val cache = cache(context)
val now = System.currentTimeMillis()
val mtime = appinfoFile(context).lastModified()
for (id in need) {
val info = answers[id]
val entry = JSONObject().put("at", now).put("mtime", mtime).put("picsAt", now)
if (info == null) entry.put("miss", true) else entry.put("name", info.name).put("depots", JSONArray(info.sharedDepots)).put("source", "pics")
cache.put(id.toString(), entry)
}
val file = AtomicFile(File(context.filesDir, CACHE))
val out = file.startWrite()
try { out.write(cache.toString().toByteArray()); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) }
return true
}
/** What is kept for [appId]: "hit", "miss" or "none" (part of a game's list fingerprint). */
fun state(context: Context, appId: Int): String = cache(context).optJSONObject(appId.toString())?.let { if (it.optBoolean("miss")) "miss" else "hit" } ?: "none"
private fun cache(context: Context): JSONObject =
runCatching { JSONObject(AtomicFile(File(context.filesDir, CACHE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else (0 until length()).map { optString(it) }
/**
* [appId]'s entry in an appinfo.vdf (versions 27, 28 and 29, the last with its keys in a string
* table), or null when the file does not have it.
*/
internal fun read(file: File, appId: Int): Info? {
if (!file.isFile) return null
RandomAccessFile(file, "r").use { raf ->
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
raf.readFully(head.array(), 0, 8)
val magic = head.getInt(0)
val version = magic and 0xFF
if (magic ushr 8 != 0x075644 || version !in 0x27..0x29) return null
var strings: List<String>? = null
var pos = 8L
if (version >= 0x29) {
raf.readFully(head.array(), 8, 8)
val tableAt = head.getLong(8)
strings = readStrings(raf, tableAt)
pos = 16L
}
val fixed = if (version >= 0x28) 4 + 4 + 8 + 20 + 4 + 20 else 4 + 4 + 8 + 20 + 4
val entry = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
while (pos + 8 <= raf.length()) {
raf.seek(pos)
raf.readFully(entry.array(), 0, 8)
val id = entry.getInt(0)
val size = entry.getInt(4).toLong() and 0xFFFFFFFFL
if (id == 0) return null
if (id == appId) {
if (size > 64L * 1024 * 1024) return null
val body = ByteArray(size.toInt())
raf.readFully(body)
val kv = ByteBuffer.wrap(body, fixed, body.size - fixed).order(ByteOrder.LITTLE_ENDIAN)
val tree = readMap(kv, strings)
@Suppress("UNCHECKED_CAST")
val app = (tree["appinfo"] as? Map<String, Any>) ?: tree
return infoOf(app)
}
pos += 8 + size
}
return null
}
}
@Suppress("UNCHECKED_CAST")
internal fun infoOf(app: Map<String, Any>): Info {
val name = (app["common"] as? Map<String, Any>)?.get("name") as? String ?: ""
val depots = (app["depots"] as? Map<String, Any>).orEmpty()
val shared = depots.mapNotNull { (key, value) ->
val depot = value as? Map<String, Any> ?: return@mapNotNull null
key.takeIf { (depot["depotfromapp"] as? String) == SHARED_APP || (key in SteamRedists.DEPOTS && depot["sharedinstall"] != null) }
}
return Info(name, shared)
}
private fun readStrings(raf: RandomAccessFile, at: Long): List<String> {
raf.seek(at)
val count = Integer.reverseBytes(raf.readInt())
val rest = ByteArray((raf.length() - at - 4).toInt().coerceAtLeast(0))
raf.readFully(rest)
val out = ArrayList<String>(count)
var start = 0
for (i in rest.indices) {
if (out.size == count) break
if (rest[i] == 0.toByte()) { out.add(String(rest, start, i - start, Charsets.UTF_8)); start = i + 1 }
}
return out
}
private fun cstring(b: ByteBuffer): String {
val start = b.position()
while (b.get() != 0.toByte()) { }
val bytes = ByteArray(b.position() - start - 1)
b.position(start); b.get(bytes); b.get()
return String(bytes, Charsets.UTF_8)
}
/** One binary key-value map, to its end marker; keys by name, or by index into [strings]. */
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
if (type == 0x08) return out
val key = if (strings != null) strings.getOrElse(b.int) { "" } else cstring(b)
when (type) {
0x00 -> out[key] = readMap(b, strings)
0x01 -> out[key] = cstring(b)
0x02 -> out[key] = b.int.toString()
0x03 -> out[key] = b.float.toString()
0x07, 0x0A -> out[key] = b.long.toString()
else -> return out
}
}
return out
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.stores.CredentialCipher
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* SteamGridDB, the art source after Steam's store: a game by its Steam appid, else by a search
* whose result must carry the same name, then its best-rated grids, heroes, logos and icons.
*
* The API key: the user's own (Steam settings › Games), sealed with [CredentialCipher] in the
* app's files, else the one the build carries (`BuildConfig.SGDB_API_KEY`, from a CI secret), else
* none, and then this step is skipped. The key travels only in the Authorization header and is
* never logged.
*/
object SteamGridDb {
private const val TAG = "SteamGridDb"
private const val API = "https://www.steamgriddb.com/api/v2"
private const val QUERY = "nsfw=false&humor=false"
@VisibleForTesting
@Volatile
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
private fun keyFile(context: Context) = File(context.filesDir, "sgdb/key.json")
/** The key the build carries, or "" (a build without the secret, a fork's PR build). */
fun builtInKey(): String = runCatching {
Class.forName("com.droiddeck.launcher.BuildConfig").getField("SGDB_API_KEY").get(null) as? String
}.getOrNull().orEmpty()
/** The user's key wins, then the build's; "" when there is neither. */
fun pick(user: String, builtIn: String): String = user.trim().ifEmpty { builtIn.trim() }
fun key(context: Context): String = pick(userKey(context), builtInKey())
/** The key the user entered, or "". */
@Synchronized
fun userKey(context: Context): String {
val f = keyFile(context)
if (!f.isFile) return ""
return try {
val json = JSONObject(f.readText())
if (CredentialCipher.isEnvelope(json)) CredentialCipher.open(json, keys) else json.optString("key")
} catch (e: Exception) {
if (CredentialCipher.isPermanent(e)) f.delete()
Log.w(TAG, "the saved key cannot be read (${e.javaClass.simpleName})")
""
}
}
/** Saves the user's key sealed (plain only where the Keystore does not work); "" clears it. */
@Synchronized
fun setUserKey(context: Context, key: String) {
val f = keyFile(context)
val value = key.trim()
if (value.isEmpty()) { f.delete(); return }
f.parentFile?.mkdirs()
val text = try {
CredentialCipher.seal(value, keys).toString()
} catch (e: Exception) {
Log.w(TAG, "keystore unavailable, the key stays plain (${e.javaClass.simpleName})")
JSONObject().put("key", value).toString()
}
val tmp = File(f.path + ".tmp")
tmp.writeText(text)
tmp.setReadable(false, false); tmp.setReadable(true, true)
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
/** The SteamGridDB game id for [steamAppId], else for a search on [name] that returns the same name; null when neither. */
fun gameId(key: String, steamAppId: Int?, name: String): Int? {
if (steamAppId != null) {
json(key, "$API/games/steam/$steamAppId")?.optJSONObject("data")?.optInt("id")?.takeIf { it > 0 }?.let { return it }
}
val term = URLEncoder.encode(name, "UTF-8").replace("+", "%20")
val results = json(key, "$API/search/autocomplete/$term")?.optJSONArray("data") ?: return null
return match(name, (0 until results.length()).map { results.getJSONObject(it) }.map { it.optInt("id") to it.optString("name") })
}
/** The id of the first result whose name matches [name] ([AddedGameArt.nameMatch]); no blind first pick. */
internal fun match(name: String, results: List<Pair<Int, String>>): Int? =
results.map { (id, got) -> id to AddedGameArt.nameMatch(name, got) }
.filter { it.second > 0 && it.first > 0 }
.maxByOrNull { it.second }?.first
/**
* Fetches what is missing into [cache] under the names Steam's art uses there (p.jpg,
* header.jpg, hero.jpg, logo.png) and the icon as icon-sgdb.png. True when anything arrived.
*/
fun download(key: String, id: Int, cache: File): Boolean {
var got = false
for ((path, local) in listOf(
"grids/game/$id?dimensions=600x900&$QUERY" to "p.jpg",
"grids/game/$id?dimensions=460x215,920x430&$QUERY" to "header.jpg",
"heroes/game/$id?$QUERY" to "hero.jpg",
"logos/game/$id?$QUERY" to "logo.png",
"icons/game/$id?$QUERY" to "icon-sgdb.png",
)) {
val dst = File(cache, local)
if (dst.isFile) continue
val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue
val url = best(items, local.endsWith(".png")) ?: continue
val bytes = AddedGameArt.get(url) ?: continue
val tmp = File(cache, "$local.tmp")
tmp.writeBytes(bytes)
if (tmp.renameTo(dst)) { got = true; File(cache, "$local.src").writeText(AddedGameArt.SGDB) }
}
return got
}
/** The best-scored [limit] images for [slot] of game [id], as (thumbnail, full image) URLs. */
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> {
val path = when (slot) {
AddedGameArt.Slot.COVER -> "grids/game/$id?dimensions=600x900&$QUERY"
AddedGameArt.Slot.BACKGROUND -> "heroes/game/$id?$QUERY"
AddedGameArt.Slot.LOGO -> "logos/game/$id?$QUERY"
AddedGameArt.Slot.ICON -> "icons/game/$id?$QUERY"
}
val items = json(key, "$API/$path")?.optJSONArray("data") ?: return emptyList()
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON).take(limit)
}
/** [items] best-scored first, as (thumbnail, full image) URLs; for a .png slot only PNGs. */
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
(0 until items.length()).map { items.getJSONObject(it) }
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
.sortedByDescending { it.optInt("score", 0) }
.map { (it.optString("thumb").takeIf { t -> t.startsWith("https://") } ?: it.optString("url")) to it.optString("url") }
/** The highest-scored image's URL; for a .png slot only a PNG. */
internal fun best(items: JSONArray, pngOnly: Boolean): String? =
(0 until items.length()).map { items.getJSONObject(it) }
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
.maxByOrNull { it.optInt("score", 0) }?.optString("url")
/** A GET with the key in its Authorization header; the body as JSON, or null. Logs the path only. */
private fun json(key: String, url: String): JSONObject? {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 10_000
c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
c.setRequestProperty("Authorization", "Bearer $key")
return try {
if (c.responseCode != 200) { Log.w(TAG, "${URL(url).path}: HTTP ${c.responseCode}"); null }
else JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
} catch (e: Exception) {
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}"); null
} finally {
c.disconnect()
}
}
}
@@ -0,0 +1,77 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import org.json.JSONObject
import java.io.File
/**
* Which Steam app a game added in DroidDeck is, kept once per game (by its folder) and used by
* everything that asks: its art ([AddedGameArt]) and its Windows components ([WinCompSources]),
* so the two never disagree.
*
* [Certainty.FILES]: the game's own files name the appid (steam_appid.txt, also under
* steam_settings/ or a parent folder; an emulator ini; Steam's appmanifest) - certain.
* [Certainty.NAME]: Steam's store search returned a game of the same name - a guess.
* [Certainty.NONE]: neither; a miss is tried again after a week.
*/
object SteamMatch {
enum class Certainty { FILES, NAME, NONE }
/** [source]: what gave the appid (GameIdentifier's source, or "store search"). */
class Match(val appId: Int?, val certainty: Certainty, val source: String, val checkedAt: Long)
private const val FILE = "steam-matches.json"
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
private fun store(context: Context) = AtomicFile(File(context.filesDir, FILE))
@Synchronized
private fun all(context: Context): JSONObject =
runCatching { JSONObject(store(context).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
@Synchronized
fun get(context: Context, key: String): Match? = all(context).optJSONObject(key)?.let { o ->
Match(
o.optInt("appId", 0).takeIf { it > 0 },
runCatching { Certainty.valueOf(o.optString("certainty")) }.getOrDefault(Certainty.NONE),
o.optString("source"), o.optLong("at"),
)
}
@Synchronized
fun put(context: Context, key: String, match: Match) {
val json = all(context).put(
key, JSONObject().put("certainty", match.certainty.name).put("source", match.source).put("at", match.checkedAt)
.apply { match.appId?.let { put("appId", it) } },
)
val atomic = store(context)
val out = atomic.startWrite()
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
}
/**
* The match for the game in [key] (its folder), [exe] its exe and [name] its title: its files
* first, always read again; else the one kept (a name match stays, a miss for a week); else
* [legacy], an appid an earlier build's art lookup kept; else [search] on the name, when given.
* Blocking ([search] asks the network).
*/
fun resolve(context: Context, key: String, exe: File, name: String, legacy: File? = null, search: ((String) -> Int?)? = null): Match {
val now = System.currentTimeMillis()
val identity = runCatching { GameIdentifier.identify(exe) }.getOrNull()
identity?.appId?.let { id ->
return Match(id, Certainty.FILES, identity.source.name.lowercase(), now).also { put(context, key, it) }
}
val known = get(context, key)
if (known != null && known.certainty == Certainty.NAME) return known
if (known != null && known.certainty == Certainty.NONE && now - known.checkedAt < RETRY_AFTER_MS) return known
legacy?.takeIf { it.isFile }?.let { runCatching { it.readText().trim().toInt() }.getOrNull() }?.takeIf { it > 0 }?.let { id ->
return Match(id, Certainty.NAME, "store search", now).also { put(context, key, it) }
}
if (search == null) return known?.takeIf { it.certainty != Certainty.FILES } ?: Match(null, Certainty.NONE, "", 0)
val id = runCatching { search(name) }.getOrNull()
val match = if (id != null && id > 0) Match(id, Certainty.NAME, "store search", now) else Match(null, Certainty.NONE, "", now)
put(context, key, match)
return match
}
}
@@ -0,0 +1,334 @@
package com.droiddeck.launcher.frontend
import android.util.Log
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
import java.util.zip.GZIPInputStream
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* Steam's own app info (PICS) for apps the client in the runtime has never seen, asked of Steam
* anonymously: a connection to one of Steam's connection managers (the list from Steam's public
* ISteamDirectory/GetCMListForConnect, the WebSocket transport Steam's web clients use), an
* anonymous logon - no account, nothing of the user's Steam sign-in - a request for the apps'
* access tokens and then their product info, the app KeyValues read for their Steamworks Shared
* depots and name, and the connection closed. What steamcmd's anonymous `app_info_print` sees.
*/
object SteamPics {
private const val TAG = "SteamPics"
private const val CM_LIST = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&format=json"
/** At most this many apps per request. */
const val BATCH = 25
private const val PROTO = 0x80000000.toInt()
private const val EMSG_MULTI = 1
private const val EMSG_LOGON = 5514
private const val EMSG_LOGON_RESPONSE = 751
private const val EMSG_LOGOFF = 706
private const val EMSG_TOKENS_REQUEST = 8905
private const val EMSG_TOKENS_RESPONSE = 8906
private const val EMSG_INFO_REQUEST = 8903
private const val EMSG_INFO_RESPONSE = 8904
private const val PROTOCOL_VERSION = 65580
/** An anonymous user's SteamID: universe Public, account type AnonUser, instance 0. */
private const val ANON_STEAMID = (1L shl 56) or (10L shl 52)
/**
* [appIds]' info, one connection for all of them in batches of [BATCH]: each known app's
* info, null for an app Steam does not know. Null when Steam could not be reached. Blocking.
*/
fun lookup(appIds: List<Int>): Map<Int, SteamAppInfo.Info?>? {
if (appIds.isEmpty()) return emptyMap()
val host = runCatching { cmHost() }.getOrNull() ?: return null
return try {
Wss(host, 20_000).use { ws ->
ws.send(message(EMSG_LOGON, header(ANON_STEAMID, 0), Proto().varint(1, PROTOCOL_VERSION.toLong()).varint(7, 16).string(6, "english").bytes()))
var steamId = 0L
var session = 0
// The logon's answer (other messages before it are passed over).
loop@ while (true) for ((emsg, hdr, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_LOGON_RESPONSE) {
val h = fields(hdr); val b = fields(body)
if ((b[1]?.firstOrNull() as? Long ?: 0L) != 1L) { Log.w(TAG, "anonymous logon refused: ${b[1]}"); return null }
steamId = h[1]?.firstOrNull() as? Long ?: 0L
session = (h[2]?.firstOrNull() as? Long ?: 0L).toInt()
break@loop
}
val out = HashMap<Int, SteamAppInfo.Info?>()
for (batch in batches(appIds)) {
val tokens = HashMap<Int, Long>()
ws.send(message(EMSG_TOKENS_REQUEST, header(steamId, session), Proto().apply { batch.forEach { varint(2, it.toLong()) } }.bytes()))
tokens@ while (true) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_TOKENS_RESPONSE) {
for (t in fields(body)[3].orEmpty()) {
val f = fields(t as ByteArray)
val id = (f[1]?.firstOrNull() as? Long)?.toInt() ?: continue
tokens[id] = f[2]?.firstOrNull() as? Long ?: 0L
}
break@tokens
}
ws.send(message(EMSG_INFO_REQUEST, header(steamId, session), Proto().apply {
batch.forEach { id -> bytes(2, Proto().varint(1, id.toLong()).apply { tokens[id]?.takeIf { it != 0L }?.let { varint(2, it) } }.bytes()) }
varint(3, 0)
}.bytes()))
var pending = true
while (pending) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_INFO_RESPONSE) {
val r = parseInfoResponse(body)
out.putAll(r.apps)
r.unknown.forEach { out[it] = null }
pending = r.pending
}
batch.forEach { if (it !in out) out[it] = null }
}
runCatching { ws.send(message(EMSG_LOGOFF, header(steamId, session), ByteArray(0))) }
Log.i(TAG, "steam pics: ${out.size} apps, ${out.values.count { it?.sharedDepots?.isNotEmpty() == true }} with shared redists")
out
}
} catch (e: Exception) {
Log.w(TAG, "steam pics: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
/** [appIds] in requests of at most [BATCH], each once. */
fun batches(appIds: List<Int>): List<List<Int>> = appIds.distinct().chunked(BATCH)
/** A connection manager for the WebSocket transport, least loaded first as Steam lists them. */
private fun cmHost(): String? {
val c = URL(CM_LIST).openConnection() as HttpURLConnection
c.connectTimeout = 10_000; c.readTimeout = 10_000
val body = try { c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
val list = JSONObject(body).optJSONObject("response")?.optJSONArray("serverlist") ?: return null
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.firstOrNull { it.optString("type") == "websockets" }?.optString("endpoint")?.substringBefore(':')
}
// ---- the product info answer ----
class InfoResponse(val apps: Map<Int, SteamAppInfo.Info>, val unknown: List<Int>, val pending: Boolean)
/** A CMsgClientPICSProductInfoResponse: each app's KeyValues read, the unknown ones, whether more follows. */
@VisibleForTesting
internal fun parseInfoResponse(body: ByteArray): InfoResponse {
val f = fields(body)
val apps = HashMap<Int, SteamAppInfo.Info>()
for (a in f[1].orEmpty()) {
val app = fields(a as ByteArray)
val id = (app[1]?.firstOrNull() as? Long)?.toInt() ?: continue
val buffer = app[5]?.firstOrNull() as? ByteArray ?: continue
val text = String(buffer, Charsets.UTF_8).trimEnd('\u0000')
infoFromKeyValues(text)?.let { apps[id] = it }
}
val unknown = f[2].orEmpty().mapNotNull { (it as? Long)?.toInt() } + f[2].orEmpty().filterIsInstance<ByteArray>().flatMap { packed(it) }
return InfoResponse(apps, unknown, (f[6]?.firstOrNull() as? Long ?: 0L) != 0L)
}
/** An app's KeyValues text ("appinfo" { "common" {...} "depots" {...} }) to its name and shared depots. */
@VisibleForTesting
internal fun infoFromKeyValues(text: String): SteamAppInfo.Info? {
// The buffer ends in a NUL, kept or not by whoever handed it over.
val tree = runCatching { KeyValues(text.trimEnd('\u0000', ' ', '\n', '\r', '\t')).read() }.getOrNull() ?: return null
@Suppress("UNCHECKED_CAST")
val app = tree["appinfo"] as? Map<String, Any> ?: return null
return SteamAppInfo.infoOf(app)
}
/** Valve's text KeyValues: quoted keys and values, nested braces. */
private class KeyValues(private val s: String) {
private var i = 0
fun read(): Map<String, Any> = map(top = true)
private fun map(top: Boolean): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (true) {
skip()
if (i >= s.length) { if (top) return out else throw IOException("unclosed") }
if (s[i] == '}') { i++; return out }
val key = token()
skip()
if (i < s.length && s[i] == '{') { i++; out[key] = map(top = false) } else out[key] = token()
}
}
private fun skip() {
while (i < s.length) {
if (s[i].isWhitespace() || s[i] == '\u0000') i++
else if (s.startsWith("//", i)) { while (i < s.length && s[i] != '\n') i++ }
else break
}
}
private fun token(): String {
if (i < s.length && s[i] == '"') {
i++
val b = StringBuilder()
while (i < s.length && s[i] != '"') {
if (s[i] == '\\' && i + 1 < s.length) { i++; b.append(when (s[i]) { 'n' -> '\n'; 't' -> '\t'; else -> s[i] }) } else b.append(s[i])
i++
}
i++
return b.toString()
}
val start = i
while (i < s.length && !s[i].isWhitespace() && s[i] != '{' && s[i] != '}') i++
if (start == i) throw IOException("token expected at $i")
return s.substring(start, i)
}
}
// ---- messages ----
private fun header(steamId: Long, session: Int) = Proto().fixed64(1, steamId).varint(2, session.toLong()).bytes()
private fun message(emsg: Int, header: ByteArray, body: ByteArray): ByteArray =
ByteBuffer.allocate(8 + header.size + body.size).order(ByteOrder.LITTLE_ENDIAN)
.putInt(emsg or PROTO).putInt(header.size).put(header).put(body).array()
/** One message off the wire as (emsg, header, body); a Multi unpacked (gunzipped when it says so) into the ones inside. */
@VisibleForTesting
internal fun messages(data: ByteArray): List<Triple<Int, ByteArray, ByteArray>> {
if (data.size < 8) return emptyList()
val b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val raw = b.int
if (raw and PROTO == 0) return emptyList()
val emsg = raw and PROTO.inv()
val hl = b.int
if (hl < 0 || 8 + hl > data.size) return emptyList()
val header = data.copyOfRange(8, 8 + hl)
val body = data.copyOfRange(8 + hl, data.size)
if (emsg != EMSG_MULTI) return listOf(Triple(emsg, header, body))
val f = fields(body)
var inner = f[2]?.firstOrNull() as? ByteArray ?: return emptyList()
if ((f[1]?.firstOrNull() as? Long ?: 0L) > 0) inner = GZIPInputStream(ByteArrayInputStream(inner)).use { it.readBytes() }
val out = ArrayList<Triple<Int, ByteArray, ByteArray>>()
val ib = ByteBuffer.wrap(inner).order(ByteOrder.LITTLE_ENDIAN)
while (ib.remaining() >= 4) {
val len = ib.int
if (len < 0 || len > ib.remaining()) break
val one = ByteArray(len).also { ib.get(it) }
out.addAll(messages(one))
}
return out
}
// ---- protobuf, the little needed ----
/** Writes protobuf fields. */
@VisibleForTesting
internal class Proto {
private val out = ByteArrayOutputStream()
private fun raw(v: Long) { var x = v; while (true) { val b = (x and 0x7F).toInt(); x = x ushr 7; if (x == 0L) { out.write(b); return }; out.write(b or 0x80) } }
fun varint(field: Int, v: Long) = apply { raw((field shl 3).toLong()); raw(v) }
fun fixed64(field: Int, v: Long) = apply { raw(((field shl 3) or 1).toLong()); for (k in 0 until 8) out.write(((v ushr (8 * k)) and 0xFF).toInt()) }
fun bytes(field: Int, b: ByteArray) = apply { raw(((field shl 3) or 2).toLong()); raw(b.size.toLong()); out.write(b) }
fun string(field: Int, s: String) = bytes(field, s.toByteArray())
fun bytes(): ByteArray = out.toByteArray()
}
/** Reads protobuf fields: number to values (Long for varint and fixed, ByteArray for length-delimited). */
@VisibleForTesting
internal fun fields(b: ByteArray): Map<Int, List<Any>> {
val out = HashMap<Int, MutableList<Any>>()
var i = 0
fun varint(): Long { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) return v } }
while (i < b.size) {
val key = varint()
val field = (key ushr 3).toInt()
val value: Any = when ((key and 7).toInt()) {
0 -> varint()
1 -> { var v = 0L; for (k in 0 until 8) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 8; v }
5 -> { var v = 0L; for (k in 0 until 4) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 4; v }
2 -> { val n = varint().toInt(); val v = b.copyOfRange(i, i + n); i += n; v }
else -> throw IOException("wire type ${key and 7}")
}
out.getOrPut(field) { ArrayList() }.add(value)
}
return out
}
private fun packed(b: ByteArray): List<Int> {
val out = ArrayList<Int>(); var i = 0
while (i < b.size) { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) break }; out.add(v.toInt()) }
return out
}
// ---- WebSocket over TLS, binary frames ----
private class Wss(host: String, timeoutMs: Int) : AutoCloseable {
// Made with the host name, so it goes out as SNI.
private val socket: SSLSocket = (SSLSocketFactory.getDefault().createSocket(host, 443) as SSLSocket).apply { soTimeout = timeoutMs }
private val out: OutputStream
private val input: DataInputStream
init {
// Host name checked against the certificate, as HTTPS does.
socket.sslParameters = socket.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
socket.startHandshake()
out = socket.outputStream
input = DataInputStream(socket.inputStream)
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
out.write(("GET /cmsocket/ HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: ${android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
out.flush()
val status = line(input) ?: throw IOException("no upgrade response")
if (!status.contains(" 101 ")) throw IOException("upgrade refused: $status")
while (true) { val l = line(input) ?: break; if (l.isEmpty()) break }
}
private fun line(s: InputStream): String? {
val buf = ByteArrayOutputStream()
while (true) {
val b = s.read()
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
buf.write(b)
}
}
fun send(payload: ByteArray) {
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
val frame = ByteArrayOutputStream()
frame.write(0x82)
when {
payload.size < 126 -> frame.write(0x80 or payload.size)
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
else -> { frame.write(0x80 or 127); for (k in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * k)) and 0xFF).toInt()) }
}
frame.write(mask)
for (k in payload.indices) frame.write((payload[k].toInt() xor mask[k % 4].toInt()) and 0xFF)
out.write(frame.toByteArray()); out.flush()
}
/** The next binary message (fragments joined); null on close. */
fun receive(): ByteArray? {
val message = ByteArrayOutputStream()
while (true) {
val b0 = input.read(); if (b0 < 0) return null
val b1 = input.read(); if (b1 < 0) return null
val fin = b0 and 0x80 != 0
val opcode = b0 and 0x0F
var len = (b1 and 0x7F).toLong()
if (len == 126L) len = input.readUnsignedShort().toLong() else if (len == 127L) len = input.readLong()
if (len > 32L * 1024 * 1024) throw IOException("frame too large")
val data = ByteArray(len.toInt()).also { input.readFully(it) }
when (opcode) {
0x8 -> return null
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
0x2, 0x0 -> { message.write(data); if (fin) return message.toByteArray() }
else -> {}
}
}
}
override fun close() {
runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
runCatching { socket.close() }
}
}
}
@@ -0,0 +1,98 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
import com.droiddeck.launcher.stores.gog.GogAuth
import com.droiddeck.launcher.stores.gog.GogDownloadManager
import org.json.JSONObject
import java.io.File
/**
* For a GOG or Epic game installed before its sidecar kept the store's list (GOG's build
* `dependencies`, Epic's manifest prerequisite): the list read once from the store's build
* manifest with the saved sign-in - the manifest alone, never a game file - and kept in the sidecar
* the way an install keeps it. Runs on the components' worker ([AutoComponents]). Without a
* sign-in or a network it is skipped quietly and tried again a week later.
*/
object StoreListBackfill {
private const val TAG = "StoreListBackfill"
private const val FILE = "store-list-backfill.json"
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
/** The store's list for the game in a sidecar: GOG's dependency ids, or Epic's prerequisite {name, path}; null when it could not be had. */
fun interface Fetcher {
fun fetch(context: Context, sidecar: StoreGameSidecar): Map<String, String>?
}
/** The store itself, with the saved sign-in. */
internal val storeFetcher = Fetcher { context, sidecar -> fromStore(context, sidecar) }
@VisibleForTesting internal var fetcher: Fetcher = storeFetcher
/** Whether [sidecar] was written before the list was kept. */
/**
* Whether [sidecar] has no list read whole: written before the list was kept, or by a build
* that could have written an empty one for a manifest it did not read right.
*/
fun needs(sidecar: StoreGameSidecar): Boolean =
(sidecar.store == Store.GOG || sidecar.store == Store.EPIC) && WinCompSources.LIST_READ !in sidecar.extra
/**
* The list for the game in [folder], fetched and kept when its sidecar lacks one and it was not
* tried in the past week. True when the sidecar changed. Blocking (network).
*/
@Synchronized
fun fill(context: Context, folder: File): Boolean {
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled && needs(it) } ?: return false
val tries = runCatching { JSONObject(AtomicFile(File(context.filesDir, FILE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
val now = System.currentTimeMillis()
if (now - tries.optLong(folder.path, 0L) < RETRY_AFTER_MS) return false
// Offline is no try: it waits for a network rather than a week.
if (!AutoComponents.online(context)) return false
val list = runCatching { fetcher.fetch(context, sidecar) }.getOrNull()
if (list == null) {
tries.put(folder.path, now)
val atomic = AtomicFile(File(context.filesDir, FILE))
val out = atomic.startWrite()
try { out.write(tries.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
Log.i(TAG, "${sidecar.title}: the ${sidecar.store.label} list could not be had; again in a week")
return false
}
// Read fresh and written whole, as an install writes it: only the list is added.
val current = StoreGameSidecar.read(folder) ?: return false
current.copy(extra = current.extra + list + (WinCompSources.LIST_READ to "1")).write(folder)
Log.i(TAG, "backfill: ${sidecar.title} " + when (sidecar.store) {
Store.GOG -> "gog deps=[${list[WinCompSources.GOG_DEPENDENCIES].orEmpty()}]"
else -> "epic prereq=" + (list[WinCompSources.EPIC_PREREQ_NAME].orEmpty().ifEmpty { list[WinCompSources.EPIC_PREREQ_PATH].orEmpty() }.ifEmpty { "none" })
})
return true
}
private fun fromStore(context: Context, sidecar: StoreGameSidecar): Map<String, String>? {
return when (sidecar.store) {
Store.GOG -> {
val token = GogAuth.validToken(context) ?: return null
GogDownloadManager.fetchDependencies(sidecar.id, sidecar.installVersion, token)
?.let { mapOf(WinCompSources.GOG_DEPENDENCIES to it.joinToString(",")) }
}
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
val namespace = sidecar.extra["namespace"].orEmpty()
val catalogItemId = sidecar.extra["catalogItemId"].orEmpty()
if (namespace.isEmpty() || catalogItemId.isEmpty()) return null
val json = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, sidecar.extra["appName"] ?: sidecar.id) ?: return null
EpicDownloadManager.fetchPrereq(json)?.let { (name, path) ->
mapOf(WinCompSources.EPIC_PREREQ_NAME to name, WinCompSources.EPIC_PREREQ_PATH to path)
}
}
else -> null
}
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import org.json.JSONObject
import java.io.File
import java.util.Locale
/**
* Which Windows components a game needs, from the list its source keeps - the source badge decides
* which list is trusted:
*
* - **Steam**: the Steamworks Shared redistributables Steam installs with the game (its
* appmanifest's SharedDepots, else the client's app info) - turned on by themselves.
* - **GOG**: the build manifest's `dependencies`, kept in the sidecar at install - on by themselves.
* - **Epic Games**: the manifest's prerequisite (usually UE4/UE5's prerequisite setup: VC++ and the
* DirectX June 2010 runtime), kept in the sidecar at install - on by themselves.
* - **Amazon Games**: fuel.json's PostInstall installers - on by themselves.
* - **Custom** whose files name a Steam appid ([SteamMatch], certain): Steam's list for that app -
* on by themselves. Matched by name only: the same list as Recommended. No match: the folder scan
* ([DependencyDetector]) as Recommended.
*
* Every store entry is mapped to catalog names here, in one place; one that maps to nothing is a
* suggestion under its own name. The folder scan is offered as Recommended for every game.
*/
object WinCompSources {
/** Whose list a finding came from; the reason line says it. */
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
/**
* One thing a list names: [component] the catalog name it maps to (null: none, a suggestion),
* [original] what the list called it, [auto] whether it is turned on by itself, [args] what
* the reason line names (Steam: the label, the game, the appid).
*/
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
// ---- mapping tables: a store's names -> catalog components ----
private val VC_YEAR = mapOf(
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010", "2012" to "vcredist2012",
"2013" to "vcredist2013", "2015" to "vcredist2015", "2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
)
private val DOTNET = mapOf(
"35" to "dotnet35", "40" to "dotnet40", "45" to "dotnet45", "451" to "dotnet452", "452" to "dotnet452",
"46" to "dotnet46", "461" to "dotnet46", "462" to "dotnet46", "47" to "dotnet472", "471" to "dotnet472",
"472" to "dotnet472", "48" to "dotnet48", "481" to "dotnet48",
)
/**
* A GOG dependency id ("MSVC2017_x64", "DirectX", "DOTNET48", "PhysX", "OpenAL", "XNA4") to its
* components; empty for one that is none of ours (ISI scripts, GOG's own helpers).
*/
fun gog(id: String): List<String> {
val key = id.uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
Regex("""^(?:MSVC|VCREDIST|VC)(\d{4})""").find(key)?.let { m -> return listOfNotNull(VC_YEAR[m.groupValues[1]]) }
Regex("""^(?:DOTNET|NETFRAMEWORK|DOTNETFRAMEWORK)(\d{2,3})""").find(key)?.let { m -> return listOfNotNull(DOTNET[m.groupValues[1]]) }
return when {
key.startsWith("DIRECTX") || key.startsWith("DX9") || key == "DXSETUP" || key.startsWith("D3DX") -> listOf("d3dx9")
key.startsWith("PHYSX") -> listOf("physx")
key.startsWith("OPENAL") -> listOf("oalinst")
key == "XNA31" || key == "XNA3" -> listOf("xna31")
key.startsWith("XNA") -> listOf("xna40")
key.startsWith("XLIVE") || key.startsWith("GFWL") -> listOf("XLiveRedist")
key.startsWith("XACT") -> listOf("xact")
else -> emptyList()
}
}
/** Unreal's prerequisite setup (UE4PrereqSetup_x64.exe, UEPrereqSetup_x64.exe): VC++ 2015-2022 and DirectX June 2010. */
private val UE_PREREQ = Regex("""(?i)ue\d?prereqsetup""")
/** An Epic prerequisite ([path] the installer it runs, [name] what it is called) to its components. */
fun epic(path: String, name: String): List<String> = when {
UE_PREREQ.containsMatchIn(path) || name.contains("UE4 Prerequisites", true) || name.contains("UE5 Prerequisites", true) ||
name.contains("Unreal Engine Prerequisites", true) -> listOf("vcredist2022", "d3dx9")
else -> DependencyDetector.componentsFor(path).ifEmpty { if (path.isEmpty()) gog(name) else emptyList() }
}
/** An installer a store runs after an install (Amazon's PostInstall "Command") to its components. */
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
/** Steamworks Shared depot ids to (component, Steam's label). */
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
// ---- what each store left at install ----
/** Sidecar keys the store installs fill. */
const val GOG_DEPENDENCIES = "dependencies"
const val EPIC_PREREQ_PATH = "prereqPath"
const val EPIC_PREREQ_NAME = "prereqName"
/** Set beside the list when it was read whole (an empty list then means the store lists none). */
const val LIST_READ = "listRead"
/** fuel.json's PostInstall commands. */
fun amazonPostInstall(folder: File): List<String> = runCatching {
val arr = JSONObject(File(folder, "fuel.json").readText()).optJSONArray("PostInstall") ?: return emptyList()
(0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("Command")?.takeIf { c -> c.isNotEmpty() } }
}.getOrDefault(emptyList())
// ---- per game ----
/** Steam's list for [appId]: the local appmanifest's SharedDepots, else the client's app info. */
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
val local = SteamRedists.detect(gameDir, appId)
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
return pairs.map { (component, label) ->
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
}
}
/** Everything known for [game], blocking (files; the client's app info; no network). */
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
val folder = game.gameFiles
val out = ArrayList<Finding>()
val store = Store.byId(game.source)
val sidecar = folder?.let { StoreGameSidecar.read(it) }
when {
game.library != Library.ADDED -> if (game.appId > 0) out += steamList(context, folder, game.appId, game.name, auto = true)
store == Store.GOG -> sidecar?.extra?.get(GOG_DEPENDENCIES)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { id ->
val parts = gog(id)
if (parts.isEmpty()) out += Finding(null, id, Origin.GOG, false)
parts.forEach { out += Finding(it, id, Origin.GOG, true) }
}
store == Store.EPIC -> {
val path = sidecar?.extra?.get(EPIC_PREREQ_PATH).orEmpty()
val name = sidecar?.extra?.get(EPIC_PREREQ_NAME).orEmpty()
if (path.isNotEmpty() || name.isNotEmpty()) {
val parts = epic(path, name)
val original = name.ifEmpty { path.substringAfterLast('/') }
if (parts.isEmpty()) out += Finding(null, original, Origin.EPIC, false)
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
}
}
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
val parts = installer(command)
val original = command.replace('\\', '/').substringAfterLast('/')
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
}
else -> {}
}
// A game added in DroidDeck (Custom, or a store's) that is a Steam app: Steam's list for it,
// on by itself when its files say so, else only recommended.
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
val id = match.appId ?: return@let
when (match.certainty) {
SteamMatch.Certainty.FILES -> out += steamList(context, folder, id, game.name, auto = true)
SteamMatch.Certainty.NAME -> out += steamList(context, folder, id, game.name, auto = false)
else -> {}
}
}
// The folder scan, for every game: what it bundles, as Recommended.
folder?.let { DependencyDetector.detect(it) }?.forEach { rec ->
out += Finding(rec.componentName, rec.reason, Origin.FOLDER, false, listOf(rec.reason, rec.kind.name))
}
return out
}
}
@@ -168,7 +168,7 @@ object Lossless {
val libraries = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
GameStorage.effective(context)?.path?.let(::File),
) + SessionPrefs.addedGamesDirs(context).map(::File)
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
}
@@ -10,16 +10,23 @@ import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
@@ -40,6 +47,7 @@ class ControllerEditorActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
immersive()
val root = FrameLayout(this)
root.setBackgroundColor(0xFF0B0F14.toInt())
@@ -48,19 +56,27 @@ class ControllerEditorActivity : ComponentActivity() {
val bar = ComposeView(this).apply {
setContent {
DroidDeckTheme {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
) {
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
controls.saveLayout()
finish()
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
snap = !snap
ControllerPrefs.setSnap(context, snap)
controls.setSnap(snap)
}
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
controls.saveLayout()
finish()
}
}
}
}
@@ -71,14 +87,27 @@ class ControllerEditorActivity : ComponentActivity() {
}
@Composable
private fun EditorButton(label: String, primary: Boolean, onClick: () -> Unit) {
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
val pal = LocalPalette.current
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
color = when {
primary -> pal.onSignal
selected == true -> pal.signal
else -> MaterialTheme.colorScheme.onBackground
},
modifier = Modifier.clip(RoundedCornerShape(50))
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
.clickable(onClick = onClick)
.background(
when {
primary -> pal.signal
selected == true -> pal.signal.copy(alpha = 0.18f)
else -> Color.White.copy(alpha = 0.06f)
},
)
.then(
if (selected == null) Modifier.clickable(onClick = onClick)
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
@@ -46,6 +46,8 @@ object ControllerPrefs {
val steamButton: Boolean,
val qamButton: Boolean,
val keyboardButton: Boolean,
val selectSteam: Boolean,
val snap: Boolean,
val customLayout: Boolean,
val mapping: Map<String, String>,
)
@@ -64,6 +66,8 @@ object ControllerPrefs {
steamButton = p.getBoolean("steamButton", true),
qamButton = p.getBoolean("qamButton", true),
keyboardButton = p.getBoolean("keyboardButton", true),
selectSteam = p.getBoolean("selectSteam", false),
snap = p.getBoolean("snap", true),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
)
@@ -119,6 +123,15 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("keyboardButton", on).apply()
}
/** The physical Select button opens the Steam menu instead of View/Select. */
fun setSelectSteam(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("selectSteam", on).apply()
}
fun setSnap(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("snap", on).apply()
}
fun layout(context: Context, width: Int, height: Int): Map<String, Pair<Float, Float>> {
val raw = prefs(context).getString("layout.${width}x$height", null) ?: return emptyMap()
return raw.split(';').mapNotNull { entry ->
@@ -94,18 +94,52 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
// native BLE reports and output reports, so the Steam client can see it as its own device.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
private static final int IMU_MODE_OFFSET = IMU_OFFSET + 4;
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
private static final int RING_FILE_SIZE = RING_SIZE + 64;
// Slot 0 only: what the pad is to the Steam client (libfakeinput's PAD_TYPE_*), | PAD_TYPE_SET.
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
private static final int PAD_TYPE_SET = 0x80000000;
public static final int PAD_TYPE_DECK = 0;
public static final int PAD_TYPE_STEAM = 1;
public static final int PAD_TYPE_XBOX = 2;
public static final int PAD_TYPE_DS4 = 3;
public static final int PAD_TYPE_DUALSENSE = 4;
/** The type chosen this process, written into a ring made after the choice too; -1 for none. */
private static volatile int padType = -1;
/** The paired controller's state, likewise written into a ring made after it connected. */
private static volatile boolean tritonConnected = false;
private static final int TRITON_OFFSET = RING_SIZE + 64;
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
// Whether the paired controller is connected: libfakeinput offers its device only then.
private static final int TRITON_CONNECTED_OFFSET = TRITON_OFFSET + 4;
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
private static final int TRITON_REPORT_MAX = 64;
private static final int TRITON_BLOCK_SIZE = 256;
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -215,11 +249,24 @@ public class FakeInputWriter {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putInt(IMU_MODE_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
int type = padType;
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
@@ -476,6 +523,23 @@ public class FakeInputWriter {
}
}
/**
* The motion the Steam client asked the Deck controller of slot {@code slot} for: its
* SETTING_IMU_MODE (0 = off), or -1 until it has said. Safe from any thread.
*/
public static int readImuMode(int slot) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return -1;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
int value = ring == null ? 0 : ring.getInt(IMU_MODE_OFFSET);
return value < 0 ? value & 0xffff : -1;
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
@@ -502,6 +566,121 @@ public class FakeInputWriter {
}
}
/**
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
*/
public static void setPadType(int type) {
padType = type;
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = RING_SLOTS[0];
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
nativeStoreFence();
ring.putInt(PAD_TYPE_OFFSET, PAD_TYPE_SET | type);
}
}
/** The paired Steam Controller came or went (SteamControllerBle): its device is plugged in or out. */
public static void setTritonConnected(int slot, boolean connected) {
tritonConnected = connected;
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
nativeStoreFence();
ring.putInt(TRITON_CONNECTED_OFFSET, connected ? 1 : 0);
}
}
/** Publishes a native Triton input report (without the HID report ID byte). */
public static void writeTritonState(int slot, int reportId, byte[] report) {
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
if ((seq & 1) != 0) seq++;
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
nativeStoreFence();
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
}
nativeStoreFence();
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
}
}
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
public static byte[] readTritonOutput(int slot, long lastSequence) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return null;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
nativeStoreFence();
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
if ((seq & 1) != 0 || seq <= lastSequence) return null;
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
byte[] output = new byte[9 + length];
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
nativeStoreFence();
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
}
}
/** The Steam client's newest unanswered feature read, or 0: its request number. */
public static long readTritonFeatureRequest(int slot) {
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return 0L;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
nativeStoreFence();
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
}
}
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
nativeStoreFence();
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
}
}
private static RingSlot tritonSlot(int slot) {
synchronized (RING_LOCK) {
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -9,6 +9,7 @@ import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
@@ -95,8 +96,11 @@ class OnScreenControls(
private var quickPressedBy = -1
private var keyboardPressedBy = -1
private var settings = ControllerPrefs.read(context)
private var snap = settings.snap
private var safe = Rect()
private var selected: String? = null
private var snapX: Float? = null
private var snapY: Float? = null
private var grabX = 0f
private var grabY = 0f
private var ignoreSaved = false
@@ -138,10 +142,18 @@ class OnScreenControls(
fun reload() {
releaseAll()
settings = ControllerPrefs.read(context)
setSnap(settings.snap)
applySettings()
relayout()
}
private fun clearSnapGuides() {
if (snapX == null && snapY == null) return
snapX = null
snapY = null
invalidate()
}
private fun applySettings() {
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
val tint = settings.tint
@@ -179,6 +191,11 @@ class OnScreenControls(
invalidate()
}
fun setSnap(on: Boolean) {
snap = on
if (!on) clearSnapGuides()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
relayout()
@@ -412,15 +429,18 @@ class OnScreenControls(
}
}
if (editing) {
val group = selected ?: return
val (x, y) = centre(group)
stroke.color = heldStroke
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
selected?.let { group ->
val (x, y) = centre(group)
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
}
} else if (!buttonsOnly) {
val (x, y) = quickCenter()
val radius = dp(18f)
@@ -587,6 +607,7 @@ class OnScreenControls(
private fun onEditTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
clearSnapGuides()
val control = controlAt(event.x, event.y)
selected = control?.group
val group = selected ?: run { invalidate(); return false }
@@ -598,14 +619,29 @@ class OnScreenControls(
MotionEvent.ACTION_MOVE -> {
val group = selected ?: return true
ignoreSaved = false
put(group, event.x + grabX, event.y + grabY)
val rawX = event.x + grabX
val rawY = event.y + grabY
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
put(group, nextX ?: rawX, nextY ?: rawY)
clamp(group)
val (x, y) = centre(group)
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
snapX = visibleX
snapY = visibleY
invalidate()
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
}
return true
}
private fun closestSnap(position: Float, targets: List<Float>): Float? =
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
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)
@@ -715,6 +751,8 @@ class OnScreenControls(
const val PHONE_MAX_HEIGHT_MM = 90f
const val SHOULDER_WIDTH = 1.45f
const val SHOULDER_HEIGHT = 0.8f
const val SNAP_DP = 9f
const val SNAP_TOLERANCE_PX = 1f
val shoulderIds = setOf("lb", "rb", "lt", "rt")
val directions = listOf("up", "right", "down", "left")
}
@@ -10,6 +10,8 @@ import android.view.MotionEvent;
import com.droiddeck.launcher.session.SessionState;
import java.io.File;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicIntegerArray;
/**
* A physical controller, republished as a synthetic evdev device the Steam client can see.
@@ -25,12 +27,29 @@ import java.io.File;
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
* Guide-then-A chord the client itself answers.
*
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
* it, and played as the handheld while its built-in pad became player 2.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
private static final int SLOT = 0;
/** Player slots: the session prepares a ring for each (SessionService). */
public static final int SLOTS = 4;
private static final float DEAD_ZONE = 0.12f;
private static final int[] JOYSTICK_AXES = {
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
};
private static final int[] CONTROLLER_BUTTONS = {
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_X,
KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1,
KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START,
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
};
// The client takes A as part of the chord only once it has had Guide held for a while, and a
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
@@ -50,11 +69,17 @@ public final class PadBridge {
chordHandler = new Handler(thread.getLooper());
}
private final File fakeInputDir;
private final FakeInputWriter writer;
private final PadState state = new PadState();
/** The input device id holding each slot, or {@link #NO_CONTROLLER}. */
private final int[] owners = new int[SLOTS];
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
private final PadState[] playerStates = new PadState[SLOTS];
private final PadState effectiveState = new PadState();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private boolean systemGuidePressed;
private boolean systemQamPressed;
private boolean qamChordActive;
@@ -78,30 +103,67 @@ public final class PadBridge {
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
// AYN's handhelds (the Thor) present their own pad as a USB device Android calls external,
// beside the 2020:0111 it re-presents each Bluetooth Xbox pad as.
private static final int AYN_VENDOR = 0x2020;
private static final int AYN_BUILT_IN_PAD = 0x0112;
/** Whether this controller is the device's own controls rather than a pad connected to it. */
static boolean isBuiltIn(InputDevice device) {
return !device.isExternal()
|| (device.getVendorId() == AYN_VENDOR && device.getProductId() == AYN_BUILT_IN_PAD);
}
/**
* The Android input device id of the physical controller that last drove the pad, or
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
* on that slot finds the motors in the player's hands (RumbleComponent).
* The Android input device id of the physical controller that last drove any slot, or
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
*/
private static volatile int activeControllerId = NO_CONTROLLER;
/** Per slot, the controller its rumble belongs to; slot 0's is none while the on-screen controls lead. */
private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS);
static {
for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER);
}
public static int activeControllerId() { return activeControllerId; }
/** The controller playing in this ring slot, or the last one used for any other slot. */
public static int controllerForSlot(int slot) {
return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId;
}
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
this.fakeInputDir = fakeInputDir;
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0);
Arrays.fill(owners, NO_CONTROLLER);
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
}
/** The physical Select button opens the Steam menu instead of View/Select (Setup → Controller). */
public void setSelectSteam(boolean on) {
selectSteam = on;
}
/** Opens the ring; safe to call more than once. */
public synchronized boolean start() {
if (!open) {
open = writer.open();
Log.i(TAG, "ring slot " + SLOT + (open ? " open" : " NOT open"));
Log.i(TAG, "ring slot 0" + (open ? " open" : " NOT open"));
}
return open;
}
public synchronized void stop() {
activeControllerId = NO_CONTROLLER;
for (int slot = 0; slot < SLOTS; slot++) {
owners[slot] = NO_CONTROLLER;
slotControllers.set(slot, NO_CONTROLLER);
if (playerWriters[slot] != null) {
playerWriters[slot].destroy();
playerWriters[slot] = null;
}
if (playerStates[slot] != null) playerStates[slot].clear();
}
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
@@ -123,8 +185,81 @@ public final class PadBridge {
// counting it would hide the on-screen controls with nothing to replace them.
if (device.isVirtual()) return false;
int sources = device.getSources();
return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|| (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
if (device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC) return true;
// Some composite keyboards advertise joystick input without any sticks or pad buttons.
// A keyboard's mouse ranges do not establish a joystick; a real hybrid's joystick ranges
// or physical controller buttons do. GAMEPAD above remains authoritative even with keys.
for (int axis : JOYSTICK_AXES) {
InputDevice.MotionRange range = device.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
if (range != null && range.getMax() > range.getMin()) return true;
}
for (boolean present : device.hasKeys(CONTROLLER_BUTTONS)) {
if (present) return true;
}
return false;
}
/** Whether this key belongs to the pad, rather than a keyboard on the same input device. */
public static boolean isControllerKeyEvent(KeyEvent event) {
InputDevice device = event.getDevice();
if (device == null || device.isVirtual()) return false;
// An explicit pad button is unambiguous even when a hybrid omits its capabilities.
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
}
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_MENU:
case KeyEvent.KEYCODE_HOME:
if (!isFromController(device)) return false;
// Without GAMEPAD, an alphabetic hybrid's keyboard arrows remain keyboard keys.
// Its ambiguous navigation keys need explicit pad/joystick event provenance;
// DPAD alone also describes ordinary keyboard arrows.
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|| event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|| event.isFromSource(InputDevice.SOURCE_JOYSTICK);
default: return false;
}
}
/** Mouse motion on a composite controller still belongs to the pointer, not its sticks. */
public static boolean isControllerMotionEvent(MotionEvent event) {
InputDevice device = event.getDevice();
// An actual joystick event, like a pad button, survives a hybrid's missing capabilities.
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
/** A Bluetooth keyboard can share a device with a joystick. Classify the key, not just
* the device, so its letters and modifiers still reach the desktop keyboard. */
public static boolean isControllerKey(KeyEvent event) {
return isFromController(event.getDevice())
&& isPadKey(event.getKeyCode(), event.getDevice().getKeyboardType());
}
public static boolean isPadKey(int keyCode, int keyboardType) {
if ((keyCode >= KeyEvent.KEYCODE_BUTTON_A && keyCode <= KeyEvent.KEYCODE_BUTTON_MODE)
|| (keyCode >= KeyEvent.KEYCODE_BUTTON_1 && keyCode <= KeyEvent.KEYCODE_BUTTON_16)) return true;
if (keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) return false;
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_HOME:
case KeyEvent.KEYCODE_MENU: return true;
default: return false;
}
}
/** Whether any real controller is attached right now. */
@@ -155,72 +290,81 @@ public final class PadBridge {
/** @return true when the event was a pad button and has been consumed. */
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isFromController(event.getDevice())) return false;
noteDevice(event.getDevice());
if (!isControllerKeyEvent(event)) return false;
int slot = slotFor(event.getDevice());
PadState pad = stateFor(slot);
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
case KeyEvent.KEYCODE_BUTTON_B: state.press(1, pressed); break;
case KeyEvent.KEYCODE_BUTTON_X: state.press(2, pressed); break;
case KeyEvent.KEYCODE_BUTTON_Y: state.press(3, pressed); break;
case KeyEvent.KEYCODE_BUTTON_L1: state.press(4, pressed); break;
case KeyEvent.KEYCODE_BUTTON_R1: state.press(5, pressed); break;
case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break;
case KeyEvent.KEYCODE_BUTTON_B: pad.press(1, pressed); break;
case KeyEvent.KEYCODE_BUTTON_X: pad.press(2, pressed); break;
case KeyEvent.KEYCODE_BUTTON_Y: pad.press(3, pressed); break;
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
case KeyEvent.KEYCODE_BUTTON_SELECT:
case KeyEvent.KEYCODE_BACK: state.press(6, pressed); break;
// Optionally (Setup → Controller) the physical Select button opens the Steam menu
// instead of View/Select: on some handhelds it sits next to the Steam button.
if (selectSteam) pad.press(PadState.GUIDE, pressed);
else pad.press(6, pressed);
break;
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
case KeyEvent.KEYCODE_BUTTON_START:
case KeyEvent.KEYCODE_MENU: state.press(7, pressed); break;
case KeyEvent.KEYCODE_BUTTON_THUMBL: state.press(8, pressed); break;
case KeyEvent.KEYCODE_BUTTON_THUMBR: state.press(9, pressed); break;
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
case KeyEvent.KEYCODE_BUTTON_THUMBL: pad.press(8, pressed); break;
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
// The client's own in-game menu is opened by this one; the interposer publishes it as
// BTN_MODE, which SDL reports as the "guide" button.
case KeyEvent.KEYCODE_BUTTON_MODE:
case KeyEvent.KEYCODE_HOME: state.press(PadState.GUIDE, pressed); break;
case KeyEvent.KEYCODE_BUTTON_L2: state.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: state.rightTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_DPAD_UP: state.up = pressed; break;
case KeyEvent.KEYCODE_DPAD_RIGHT: state.right = pressed; break;
case KeyEvent.KEYCODE_DPAD_DOWN: state.down = pressed; break;
case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break;
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
default: return false;
}
noteDevice(event.getDevice(), slot);
if (pressed) notePlayerInput();
statButtons++;
scheduleStats();
publish();
publish(slot);
return true;
}
/** @return true when the event was a pad's sticks/triggers and has been consumed. */
public synchronized boolean onMotionEvent(MotionEvent event) {
if (!isFromController(event.getDevice())) return false;
if (!isControllerMotionEvent(event)) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
noteDevice(event.getDevice());
int slot = slotFor(event.getDevice());
PadState pad = stateFor(slot);
noteDevice(event.getDevice(), slot);
statAxes++;
scheduleStats();
state.leftX = axis(event, MotionEvent.AXIS_X);
pad.leftX = axis(event, MotionEvent.AXIS_X);
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
// pad reports as "down" is what the client is told.
state.leftY = axis(event, MotionEvent.AXIS_Y);
state.rightX = axis(event, MotionEvent.AXIS_Z);
state.rightY = axis(event, MotionEvent.AXIS_RZ);
pad.leftY = axis(event, MotionEvent.AXIS_Y);
pad.rightX = axis(event, MotionEvent.AXIS_Z);
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
// Some pads only report the triggers on BRAKE/GAS.
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
state.leftTrigger = lt;
state.rightTrigger = rt;
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
state.up = hatY < -0.5f;
state.right = hatX > 0.5f;
state.down = hatY > 0.5f;
state.left = hatX < -0.5f;
if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) {
pad.up = hatY < -0.5f;
pad.right = hatX > 0.5f;
pad.down = hatY > 0.5f;
pad.left = hatX < -0.5f;
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
notePlayerInput();
}
publish();
publish(slot);
return true;
}
@@ -231,12 +375,30 @@ public final class PadBridge {
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
notePlayerInput();
statOnScreen++;
scheduleStats();
publish();
}
/**
* A controller the app reads itself rather than through Android's input (the Steam Controller
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
*/
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
mutation.accept(state);
if (changed) {
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statButtons++;
scheduleStats();
}
publish();
}
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
@@ -255,6 +417,31 @@ public final class PadBridge {
public synchronized void releaseAll() {
state.clear();
publish();
for (int slot = 1; slot < SLOTS; slot++) {
if (playerWriters[slot] == null) continue;
playerStates[slot].clear();
publish(slot);
}
}
/**
* Gives every connected controller its slot now, so the client lists each one as it is
* connected instead of on its first press: a further player's pad is plugged in, at rest.
*/
public synchronized void claimConnected() {
for (int id : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(id);
if (!isFromController(device)) continue;
int slot = slotFor(device);
if (slot > 0 && playerWriters[slot] == null) publish(slot);
}
}
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
public synchronized void onDeviceRemoved(int deviceId) {
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] == deviceId) free(slot);
}
}
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
@@ -310,16 +497,88 @@ public final class PadBridge {
publish();
}
/**
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
* of its own), else player 1's.
*/
private int slotFor(InputDevice device) {
int id = device.getId();
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] == id) return slot;
}
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
}
if (isBuiltIn(device)) {
// A connected pad playing as the device's own controls moves to a player's slot of its own.
int was = owners[0];
if (was != NO_CONTROLLER) {
free(0);
InputDevice other = InputDevice.getDevice(was);
if (other != null && !isBuiltIn(other)) {
int to = claim(other, 1);
if (to > 0) publish(to);
}
}
owners[0] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
return 0;
}
int slot = claim(device, builtInConnected() ? 1 : 0);
return slot >= 0 ? slot : 0;
}
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
private int claim(InputDevice device, int from) {
for (int slot = from; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER) continue;
owners[slot] = device.getId();
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
return slot;
}
return -1;
}
private static boolean builtInConnected() {
for (int id : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(id);
if (isFromController(device) && isBuiltIn(device)) return true;
}
return false;
}
private PadState stateFor(int slot) {
return slot == 0 ? state : playerStates[slot];
}
private void free(int slot) {
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
owners[slot] = NO_CONTROLLER;
slotControllers.set(slot, NO_CONTROLLER);
if (slot == 0) {
state.clear();
publish();
return;
}
playerStates[slot].clear();
if (playerWriters[slot] != null) {
playerWriters[slot].destroy();
playerWriters[slot] = null;
}
}
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
private void noteDevice(InputDevice device, int slot) {
activeControllerId = device.getId();
slotControllers.set(slot, device.getId());
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
if (seenDevices.add(device.getId())) {
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); slot %d, ring %s, deck pad %b",
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
open ? "open" : "not open yet", SessionState.getDeckPad()));
slot, open ? "open" : "not open yet", SessionState.getDeckPad()));
}
}
@@ -339,6 +598,23 @@ public final class PadBridge {
statButtons = statAxes = statOnScreen = statDropped = 0;
}
private void publish(int slot) {
if (slot == 0) {
publish();
return;
}
FakeInputWriter player = playerWriters[slot];
if (player == null) {
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
if (!player.open()) {
statDropped++;
return;
}
playerWriters[slot] = player;
}
player.writePad(playerStates[slot]);
}
private void publish() {
if (!open && !start()) {
statDropped++;
@@ -20,9 +20,10 @@ import kotlin.math.roundToInt
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
* Sensors run only while the session is on screen with the pad a Deck controller, and the client
* wants motion (see [follow]), sampled as often as the Deck reports (4 ms); each reading goes
* straight into the pad's ring ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report
* picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
@@ -32,6 +33,11 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
private var reading = false
private var idlePolls = 0
// Asking the display costs more than the rest of a reading, which arrives every 2 ms.
private var turn = Surface.ROTATION_0
private var turnAt = 0L
fun start() {
if (thread != null || sensors == null) return
@@ -45,8 +51,13 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
handler.post(object : Runnable {
override fun run() {
if (!active) return
follow(handler, gyroscope, accelerometer)
handler.postDelayed(this, POLL_MS)
}
})
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
@@ -54,30 +65,71 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
// On the worker, after any poll or reading already queued there, so nothing registers again
// or overwrites the still controller.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
sensors?.unregisterListener(this)
reading = false
idlePolls = 0
still()
}
worker.quitSafely()
}
/**
* Reads the sensors while the client wants motion: the Deck's IMU mode (unknown counts as
* wanted), or Steam's touch controls asking for gyro. A game whose layout has no gyro turns the
* mode off; Steam's own menus keep it on. The client also turns it off and on again for a few
* ms whenever it re-applies a layout, so the sensors stop only after two polls without it.
*/
private fun follow(handler: Handler, gyroscope: Sensor?, accelerometer: Sensor?) {
val touch = SteamTouchDevice.current
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
idlePolls = if (wanted) 0 else idlePolls + 1
if (wanted && !reading) {
reading = true
turnAt = 0L
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: reading")
} else if (reading && idlePolls >= 2) {
reading = false
sensors?.unregisterListener(this)
still()
Log.i(TAG, "pad motion: paused, the client wants none")
}
}
/** A still controller, not one frozen mid-turn. */
private fun still() {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
if (event.timestamp - turnAt > ROTATION_REFRESH_NS) {
turn = rotation()
turnAt = event.timestamp
}
// Natural orientation to the screen's: Android's display-rotation transform.
val (x, y) = when (rotation()) {
Surface.ROTATION_90 -> -values[1] to values[0]
Surface.ROTATION_180 -> -values[0] to -values[1]
Surface.ROTATION_270 -> values[1] to -values[0]
else -> values[0] to values[1]
val x: Float
val y: Float
when (turn) {
Surface.ROTATION_90 -> { x = -values[1]; y = values[0] }
Surface.ROTATION_180 -> { x = -values[0]; y = -values[1] }
Surface.ROTATION_270 -> { x = values[1]; y = -values[0] }
else -> { x = values[0]; y = values[1] }
}
val z = values[2]
val (target, scale) = when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
val target: ShortArray
val scale: Float
when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> { target = gyro; scale = GYRO_COUNTS_PER_RAD_S }
Sensor.TYPE_ACCELEROMETER -> { target = accel; scale = ACCEL_COUNTS_PER_M_S2 }
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
@@ -85,6 +137,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
target[1] = counts(-z * scale)
target[2] = counts(y * scale)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
@@ -97,6 +150,9 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
/** How often [follow] checks whether the client wants motion. */
private const val POLL_MS = 500L
private const val ROTATION_REFRESH_NS = 250_000_000L
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}
@@ -0,0 +1,362 @@
package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
import java.util.UUID
/**
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
*
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
*/
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
class SteamControllerBle(private val context: Context) {
private val thread = HandlerThread("steam-controller-ble").apply { start() }
private val handler = Handler(thread.looper)
private val outputReports = mutableMapOf<Int, UUID>()
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
private var gatt: BluetoothGatt? = null
private var running = false
private var input: UUID? = null
private var reportId = 0
private var lizardOffSent = false
private var reportedNone = false
private var discoveries = 0
private var outputSequence = 0L
private var outputWritePending = false
/** The Steam client's feature read being answered, so the poll queues each one once. */
private var featureRead = 0L
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
fun start() = handler.post {
if (running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
}
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
fun stop() = handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
}
private fun connect() {
if (!running || gatt != null) return
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
.getOrNull()
val device = bonded?.firstOrNull(::isSteamController)
if (device == null) {
if (!reportedNone) {
reportedNone = true
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
}
handler.postDelayed(::connect, RETRY_MS)
return
}
Log.i(TAG, "connecting to \"${device.name}\"")
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
}
private fun disconnect(why: String) {
val g = gatt ?: return
gatt = null
discoveries = 0
input = null
lizardOffSent = false
outputWritePending = false
featureRead = 0L
queuedWrites.clear()
outputReports.clear()
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
g.disconnect()
g.close()
release()
Log.i(TAG, "disconnected: $why")
listener?.invoke()
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (g !== gatt) return
if (newState == BluetoothProfile.STATE_CONNECTED) {
g.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
disconnect("link lost (status $status)")
if (running) handler.postDelayed(::connect, RETRY_MS)
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (g !== gatt) return
if (status != BluetoothGatt.GATT_SUCCESS) {
disconnect("service discovery failed (status $status)")
if (running) handler.postDelayed(::connect, RETRY_MS)
return
}
// The Triton needs data length extensions, which Android enables for a large MTU.
if (!g.requestMtu(517)) subscribe(g)
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
if (g === gatt) subscribe(g)
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
FakeInputWriter.setTritonConnected(SLOT, connected)
listener?.invoke()
if (connected) {
refreshLizardMode()
pollOutput()
}
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (g !== gatt) return
if (characteristic.uuid == REPORT && !lizardOffSent) {
lizardOffSent = true
Log.i(TAG, "lizard mode off (status $status)")
} else if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
}
outputWritePending = false
writeNextOutput()
}
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
}
@Deprecated("Below Android 13 only", ReplaceWith(""))
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
@Suppress("DEPRECATION")
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
}
// Android 13 hands the value over with the call; older ones only through the characteristic.
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (g === gatt && characteristic.uuid == input) onState(value)
}
@Deprecated("Below Android 13 only", ReplaceWith(""))
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
}
}
private fun subscribe(g: BluetoothGatt) {
val service = g.getService(SERVICE)
if (service == null) {
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
g.services.joinToString { it.uuid.toString().take(8) })
// Android may answer from the service list its HID host cached, which can lack the
// vendor service: ask the controller again before giving up on it.
if (++discoveries < 3) {
refreshCache(g)
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
} else {
disconnect("no Valve service")
if (running) handler.postDelayed(::connect, RETRY_MS)
}
return
}
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
if (chr == null) {
disconnect("no state report characteristic")
return
}
outputReports.clear()
service.characteristics.forEach { characteristic ->
val uuid = characteristic.uuid.toString().lowercase()
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
val outputId = characteristicId - OUTPUT_UUID_OFFSET
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
}
}
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
input = chr.uuid
g.setCharacteristicNotification(chr, true)
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
g.writeDescriptor(cccd)
}
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
private fun refreshCache(g: BluetoothGatt) {
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
}
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
private fun refreshLizardMode() {
if (!connected) return
val msg = ByteArray(FEATURE_BYTES)
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
enqueueOutput(REPORT, msg)
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
}
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
private fun onState(data: ByteArray) {
if (data.size < STATE_BYTES) return
FakeInputWriter.writeTritonState(SLOT, reportId, data)
}
/** A neutral report prevents a link lost mid-press from leaving controls held. */
private fun release() {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
}
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
private fun pollOutput() {
if (!running || !connected || gatt == null) return
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
}
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
if (request != 0L && request != featureRead) {
featureRead = request
// Behind any queued write, so a read answers the feature report written before it.
queuedWrites.addLast(REPORT to null)
writeNextOutput()
}
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
}
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
private fun onFeatureRead(value: ByteArray?) {
outputWritePending = false
if (featureRead != 0L) {
if (value == null) Log.w(TAG, "feature read failed")
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
}
writeNextOutput()
}
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
if (report.size < 2) return
val characteristic = if (feature) {
REPORT
} else {
val id = report[0].toInt() and 0xff
outputReports[id] ?: run {
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
return
}
}
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
}
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
queuedWrites.addLast(characteristic to value)
writeNextOutput()
}
private fun writeNextOutput() {
if (outputWritePending) return
val g = gatt ?: return
val (uuid, value) = queuedWrites.pollFirst() ?: return
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
if (characteristic == null) {
Log.w(TAG, "BLE output characteristic $uuid disappeared")
return
}
if (value == null) {
if (g.readCharacteristic(characteristic)) {
outputWritePending = true
} else {
queuedWrites.addFirst(uuid to null)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
}
return
}
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = value
@Suppress("DEPRECATION")
if (!g.writeCharacteristic(characteristic)) {
queuedWrites.addFirst(uuid to value)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
} else {
outputWritePending = true
}
}
companion object {
private const val TAG = "SteamControllerBle"
/** The paired controller's native Triton reports live in slot 0's extended ring. */
private const val SLOT = 0
private const val RETRY_MS = 5_000L
private const val LIZARD_REFRESH_MS = 3_000L
private const val OUTPUT_POLL_MS = 4L
private const val OUTPUT_RETRY_MS = 10L
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
private const val FEATURE_BYTES = 62
private const val STATE_BYTES = 45
private const val OUTPUT_UUID_PREFIX = "100f6c"
private const val OUTPUT_UUID_OFFSET = 0x35
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private fun isSteamController(device: BluetoothDevice): Boolean =
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
/** Whether a session should expose a Triton HID node for a paired controller. */
@JvmStatic
fun hasPairedController(context: Context): Boolean {
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
.getOrDefault(false)
}
/** A Steam Controller is connected and feeding its native HID reports. */
@Volatile @JvmStatic
var connected = false
private set
/** Notifies the activity when [connected] changes. */
@Volatile @JvmStatic
var listener: (() -> Unit)? = null
}
}
@@ -0,0 +1,285 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import java.io.File
/**
* What each touch control is bound to, and the icon it shows - Steam's, as its configurator keeps
* them.
*
* A Steam Input binding is one string: `<action>, <label>, <icon>, <#foreground #background>`, e.g.
* `xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4`. The icon is a file name looked
* up as Steam does (steamui.so): the game's own `TouchMenuIcons` folder, then the binding icon
* library `tenfoot/resource/images/library/controller/binding_icons`. Steam draws it filled with
* the foreground colour through its own alpha (mask-image) on a button of the background colour.
* Steam Link is sent these per control by the client it streams from; here they are read from the
* game's touch config, and editing one writes the binding string back, as the configurator does.
*/
object SteamTouchBindings {
/** Steam's binding icon colours (the configurator's palette, steamui sp.js), and its defaults. */
val PALETTE = listOf(
"#FFFFFF", "#E4E4E4", "#AAAAAA", "#787878", "#434343", "#222222", "#0000AD", "#0045AD", "#0074AD",
"#00ADAD", "#33AD69", "#00AD3D", "#00AD00", "#48B119", "#74AD00", "#96AD00", "#ADA200", "#AD5D00",
"#AD3A00", "#AD0000", "#AD0051", "#AD007F", "#AD00AD", "#6800AD", "#4800AD",
)
const val DEFAULT_FOREGROUND = "#232323"
const val DEFAULT_BACKGROUND = "#E4E4E4"
/** A parsed binding string. Empty strings for absent fields. */
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
fun compose(): String =
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
val hasIcon get() = icon.isNotEmpty()
}
fun parse(binding: String): Binding {
val parts = binding.split(',').map { it.trim() }
val colors = parts.getOrNull(3).orEmpty().split(' ').filter { it.startsWith("#") }
return Binding(parts.getOrNull(0).orEmpty(), parts.getOrNull(1).orEmpty(), parts.getOrNull(2).orEmpty(),
colors.getOrNull(0).orEmpty(), colors.getOrNull(1).orEmpty())
}
/** Where a control's binding lives: its input in the group the preset puts on its source. */
data class Ref(val groupId: String, val input: String)
/** The source and input names a control reads (D-pad: its four directions). */
private fun inputsOf(type: Int): List<Pair<String, String>> = when (type) {
SteamTouchConfig.A -> listOf("button_diamond" to "button_a")
SteamTouchConfig.B -> listOf("button_diamond" to "button_b")
SteamTouchConfig.X -> listOf("button_diamond" to "button_x")
SteamTouchConfig.Y -> listOf("button_diamond" to "button_y")
SteamTouchConfig.SELECT -> listOf("switch" to "button_menu")
SteamTouchConfig.START -> listOf("switch" to "button_escape")
SteamTouchConfig.BUMPER_LEFT -> listOf("switch" to "left_bumper")
SteamTouchConfig.BUMPER_RIGHT -> listOf("switch" to "right_bumper")
SteamTouchConfig.TRIGGER_LEFT -> listOf("left_trigger" to "click")
SteamTouchConfig.TRIGGER_RIGHT -> listOf("right_trigger" to "click")
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> listOf("joystick" to "click")
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> listOf("right_joystick" to "click")
SteamTouchConfig.MACRO_1_FINGER -> listOf("switch" to "button_macro_1finger")
SteamTouchConfig.MACRO_2_FINGER -> listOf("switch" to "button_macro_2finger")
SteamTouchConfig.DPAD -> DPAD_INPUTS.map { "dpad" to it }
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> listOf("switch" to "button_macro${type - SteamTouchConfig.MACRO_0}")
else -> emptyList()
}
/** D-pad directions, in the order [refsFor] returns them: up, down, left, right. */
val DPAD_INPUTS = listOf("dpad_north", "dpad_south", "dpad_west", "dpad_east")
/**
* Where control [type]'s bindings are for preset [preset] (the action set less one) and its
* active [layers]: a layer's group wins over the base's for the same source.
*/
fun refsFor(mappings: KeyValues.Node, type: Int, preset: Int, layers: List<Int> = emptyList()): List<Ref?> {
val presets = mappings.children("preset").associateBy { it.string("id")?.toIntOrNull() ?: -1 }
val stack = layers.reversed().mapNotNull { presets[it - 1] } + listOfNotNull(presets[preset] ?: presets.values.firstOrNull())
return inputsOf(type).map { (source, input) ->
stack.firstNotNullOfOrNull { p -> groupFor(p, source)?.let { Ref(it, input) } }
}
}
private fun groupFor(preset: KeyValues.Node, source: String): String? =
preset.child("group_source_bindings")?.entries?.firstOrNull { (_, v) ->
val parts = (v as? String)?.split(' ') ?: return@firstOrNull false
parts.getOrNull(0) == source && parts.getOrNull(1) == "active" && !parts.contains("modeshift")
}?.first
/** The binding at [ref]: its first activator's first binding (Full_Press when there is one). */
fun bindingAt(mappings: KeyValues.Node, ref: Ref): Binding? {
val group = mappings.children("group").firstOrNull { it.string("id") == ref.groupId } ?: return null
val activators = group.child("inputs")?.child(ref.input)?.child("activators") ?: return null
val activator = activators.child("Full_Press") ?: activators.entries.firstNotNullOfOrNull { it.second as? KeyValues.Node }
return activator?.child("bindings")?.string("binding")?.let(::parse)
}
/** A short glyph for a binding with no icon or label: what it presses. */
fun glyph(binding: Binding?): String? {
val words = binding?.action?.split(' ') ?: return null
val arg = words.getOrNull(1)?.uppercase() ?: return null
return when (words[0]) {
"xinput_button" -> XINPUT_GLYPHS[arg] ?: arg
"key_press" -> KEY_GLYPHS[arg] ?: arg.take(4)
"mouse_button" -> when (arg) { "LEFT" -> "LMB"; "RIGHT" -> "RMB"; "MIDDLE" -> "MMB"; else -> "M" + arg.take(2) }
"mouse_wheel" -> if (arg.contains("UP")) "▲" else "▼"
else -> null
}
}
private val XINPUT_GLYPHS = mapOf(
"SHOULDER_LEFT" to "LB", "SHOULDER_RIGHT" to "RB", "TRIGGER_LEFT" to "LT", "TRIGGER_RIGHT" to "RT",
"JOYSTICK_LEFT" to "L3", "JOYSTICK_RIGHT" to "R3", "START" to "☰", "SELECT" to "⧉", "BACK" to "⧉",
"DPAD_UP" to "▲", "DPAD_DOWN" to "▼", "DPAD_LEFT" to "◀", "DPAD_RIGHT" to "▶",
)
private val KEY_GLYPHS = mapOf(
"RETURN" to "⏎", "SPACE" to "␣", "ESCAPE" to "Esc", "TAB" to "Tab", "BACKSPACE" to "⌫",
"LEFT_SHIFT" to "⇧", "RIGHT_SHIFT" to "⇧", "LEFT_CONTROL" to "Ctrl", "RIGHT_CONTROL" to "Ctrl",
"LEFT_ALT" to "Alt", "RIGHT_ALT" to "Alt", "UP_ARROW" to "↑", "DOWN_ARROW" to "↓",
"LEFT_ARROW" to "←", "RIGHT_ARROW" to "→",
)
// ---- Writing a binding back ----
/**
* Rewrites every binding of [ref]'s input in config [text] (VDF) with [change], keeping the rest
* of the file byte for byte. Returns the new text, or null when the input is not there.
*/
fun rewrite(text: String, ref: Ref, change: (Binding) -> Binding): String? {
val tokens = Vdf.tokens(text)
val group = Vdf.blocks(tokens, 0, tokens.size, "group").firstOrNull { (s, e) ->
Vdf.value(tokens, s, e, "id") == ref.groupId
} ?: return null
val inputs = Vdf.blocks(tokens, group.first, group.second, "inputs").firstOrNull() ?: return null
val input = Vdf.blocks(tokens, inputs.first, inputs.second, ref.input).firstOrNull() ?: return null
val targets = (input.first until input.second).filter { i ->
tokens[i].text.equals("binding", true) && tokens[i].quoted && i + 1 < input.second && tokens[i + 1].quoted
}.map { tokens[it + 1] }
if (targets.isEmpty()) return null
val out = StringBuilder(text)
for (t in targets.sortedByDescending { it.start }) out.replace(t.start, t.end, change(parse(t.text)).compose())
return out.toString()
}
/** A VDF tokenizer that keeps where each token sits, for edits that leave the rest untouched. */
private object Vdf {
class Token(val text: String, val quoted: Boolean, val start: Int, val end: Int)
fun tokens(text: String): List<Token> {
val out = mutableListOf<Token>()
var i = 0
while (i < text.length) {
val c = text[i]
when {
c.isWhitespace() -> i++
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
c == '{' || c == '}' -> { out += Token(c.toString(), false, i, i + 1); i++ }
c == '"' -> {
val start = i + 1
i++
while (i < text.length && text[i] != '"') { if (text[i] == '\\') i++; i++ }
out += Token(text.substring(start, minOf(i, text.length)), true, start, minOf(i, text.length))
i++
}
else -> {
val start = i
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
out += Token(text.substring(start, i), false, start, i)
}
}
}
return out
}
/** The bodies (token index ranges inside the braces) of direct children named [key] of the
* block spanning [from, to). */
fun blocks(tokens: List<Token>, from: Int, to: Int, key: String): List<Pair<Int, Int>> {
val found = mutableListOf<Pair<Int, Int>>()
var i = from
var depth = 0
while (i < to) {
val t = tokens[i]
if (t.text == "{" && !t.quoted) depth++
else if (t.text == "}" && !t.quoted) depth--
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].text == "{" && !tokens[i + 1].quoted) {
val open = i + 1
var d = 0
var j = open
while (j < to) {
if (tokens[j].text == "{" && !tokens[j].quoted) d++
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
j++
}
found += (open + 1) to j
i = j + 1
continue
}
i++
}
// The whole file is one top-level block ("controller_mappings"): look inside it too.
if (found.isEmpty() && from == 0) {
val top = tokens.indexOfFirst { it.text == "{" && !it.quoted }
if (top > 0) {
var d = 0
var j = top
while (j < to) {
if (tokens[j].text == "{" && !tokens[j].quoted) d++
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
j++
}
return blocks(tokens, top + 1, j, key)
}
}
return found
}
/** A direct key-value of the block spanning [from, to). */
fun value(tokens: List<Token>, from: Int, to: Int, key: String): String? {
var depth = 0
var i = from
while (i < to) {
val t = tokens[i]
if (t.text == "{" && !t.quoted) depth++
else if (t.text == "}" && !t.quoted) depth--
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].quoted) return tokens[i + 1].text
i++
}
return null
}
}
// ---- Icons ----
private val cache = object : LruCache<String, Bitmap>(24 * 1024 * 1024) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}
private val missing = HashSet<String>()
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
/** The game's own icons folder (`TouchMenuIcons` in its install directory), when it has one. */
fun gameIconsDir(context: Context, appId: Int): File? = try {
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
} catch (_: Exception) {
null
}
fun iconFile(context: Context, appId: Int, name: String): File? {
if (name.isEmpty() || name.contains('/')) return null
gameIconsDir(context, appId)?.let { File(it, name) }?.takeIf { it.isFile }?.let { return it }
File(libraryDir(context), name).takeIf { it.isFile }?.let { return it }
return File(SteamTouchConfig.steamRoot(context), "steamui/images/controller/$name").takeIf { it.isFile }
}
/** An icon, decoded at about [size] px. Blocking (reads a file); cached. */
fun icon(context: Context, appId: Int, name: String, size: Int = 128): Bitmap? {
val key = "$appId/$name/$size"
cache.get(key)?.let { return it }
if (key in missing) return null
val file = iconFile(context, appId, name)
val bitmap = file?.let {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(it.path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= size) sample *= 2
BitmapFactory.decodeFile(it.path, BitmapFactory.Options().apply { inSampleSize = sample })
}
if (bitmap == null) missing += key else cache.put(key, bitmap)
return bitmap
}
fun cachedIcon(appId: Int, name: String, size: Int = 128): Bitmap? = cache.get("$appId/$name/$size")
/** Icon names for the picker: the game's own first, then Steam's library by category. */
fun iconNames(context: Context, appId: Int): List<String> {
val own = gameIconsDir(context, appId)?.list()?.filter { it.endsWith(".png", true) }?.sorted().orEmpty()
val library = libraryDir(context).list()?.filter { it.endsWith(".png", true) && it != "special_blank.png" }?.sorted().orEmpty()
return own + library.filter { it !in own }
}
}
@@ -0,0 +1,654 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
/**
* Steam's touch configs, read and written where Steam keeps them.
*
* A touch config is a Steam Input config (`controller_mobile_touch`) like any other - bindings per
* action set, synced to the account, shared as community configs - with one extra key,
* `touch_layout`: where each on-screen control sits, per action set, as a hex-encoded
* `CVirtualControllerLayouts` protobuf (steammessages_virtualcontroller.proto). Steam Link is
* handed both by the client it streams from; here the client tells the device only the app and
* action set it is on (SteamTouchDevice), so the config is found on disk: the file the client's
* console log says it loaded for the touch controller, else the app's entry in
* `configset_controller_mobile_touch.vdf`. With no layout in it, controls go where Steam Link puts
* them by default. Saving a layout writes `touch_layout` into the app's autosave config, as the
* client does when Steam Link saves one; Steam Cloud picks that file up like any autosave.
* (docs/development/steam-touch-controller.md.)
*/
object SteamTouchConfig {
private const val TAG = "SteamTouch"
// EControllerElementType.
const val THUMB = 0
const val STEAM = 1
const val JOYSTICK_LEFT = 2
const val JOYSTICK_LEFT_BUTTON = 3
const val JOYSTICK_RIGHT = 4
const val JOYSTICK_RIGHT_BUTTON = 5
const val DPAD = 6
const val A = 7
const val B = 8
const val X = 9
const val Y = 10
const val SELECT = 11
const val START = 12
const val TRIGGER_LEFT = 13
const val TRIGGER_RIGHT = 14
const val BUMPER_LEFT = 15
const val BUMPER_RIGHT = 16
const val MACRO_0 = 17 // 17..24 = macros 0-7
const val TRACKPAD_CENTER = 25
const val TRACKPAD_LEFT = 26
const val TRACKPAD_RIGHT = 27
const val KEYBOARD = 28
const val MACRO_1_FINGER = 30
const val MACRO_2_FINGER = 31
const val MAGNIFYING_GLASS = 29
const val PASTE = 34
/** Controls a layout may place though no binding asks for them (Steam Link's tray). */
val OPTIONAL = setOf(PASTE)
/** A control's place: centre as a fraction of the screen, and its scale. */
data class Element(val type: Int, val visible: Boolean, val x: Float, val y: Float, val xScale: Float = 1f, val yScale: Float = 1f)
data class Layout(val actionSet: Int, val elements: List<Element>, val color: FloatArray?, val version: Int?)
/** A decoded `touch_layout`; [rest] is every other top-level field, written back untouched. */
class Layouts(val layouts: List<Layout>, val rest: ByteArray) {
fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id }
/** The per-game options Steam Link keeps beside the layouts (CVirtualControllerLayouts
* fields 2-4): input mode, mouse mode, trackpad sensitivity. */
val options: Options get() = Options.decode(rest)
}
/** EInputMode: 1 mouse, 2 controller, 3 both. EMouseMode: 2 absolute cursor (direct touch),
* 3 touch, 4 relative (trackpad); 0 = not set. */
data class Options(val inputMode: Int = INPUT_CONTROLLER, val mouseMode: Int = 0, val trackpadSensitivity: Float = 1f) {
/** [rest] with these options in place of its own fields 2-4, the rest kept as it was. */
fun encodeInto(rest: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
val p = Proto(rest)
while (p.more()) {
val start = p.pos
val (field, wire) = p.key()
p.skip(wire)
if (field !in 2..4) out.write(rest, start, p.pos - start)
}
tag(out, 2, 0); varint(out, inputMode.toLong())
if (mouseMode != 0) { tag(out, 3, 0); varint(out, mouseMode.toLong()) }
tag(out, 4, 5); fixed(out, trackpadSensitivity)
return out.toByteArray()
}
companion object {
fun decode(rest: ByteArray): Options {
var input = INPUT_CONTROLLER
var mouse = 0
var sensitivity = 1f
val p = Proto(rest)
while (p.more()) {
val (field, wire) = p.key()
when {
field == 2 && wire == 0 -> input = p.varint().toInt()
field == 3 && wire == 0 -> mouse = p.varint().toInt()
field == 4 && wire == 5 -> sensitivity = p.float()
else -> p.skip(wire)
}
}
return Options(input, mouse, sensitivity)
}
}
}
const val INPUT_MOUSE = 1
const val INPUT_CONTROLLER = 2
const val INPUT_BOTH = 3
const val MOUSE_ABSOLUTE = 2
const val MOUSE_TOUCH = 3
const val MOUSE_RELATIVE = 4
/** The config's action sets in preset order: id as the device is told (preset id + 1) and title. */
fun actionSets(config: Config): List<Pair<Int, String>> {
val mappings = config.mappings ?: return listOf(1 to "Default")
val titles = mappings.child("actions")?.entries?.mapNotNull { (key, v) ->
(v as? KeyValues.Node)?.let { key to (it.string("title") ?: key) }
}?.toMap().orEmpty()
val sets = mappings.children("preset").mapNotNull { preset ->
val id = preset.string("id")?.toIntOrNull() ?: return@mapNotNull null
val name = preset.string("name") ?: ""
(id + 1) to (titles[name]?.takeUnless { it.startsWith("#") } ?: name.ifEmpty { "Set ${id + 1}" })
}.sortedBy { it.first }
return sets.ifEmpty { listOf(1 to "Default") }
}
/** A config as the overlay needs it. */
class Config(
val file: File?,
val text: String?,
val layouts: Layouts?,
/** Bound controls per preset (action set id - 1). */
val available: Map<Int, Set<Int>>,
/** The parsed config, for its bindings (SteamTouchBindings). */
val mappings: KeyValues.Node? = null,
) {
/** The config's own title, as Steam Link's tray shows it ("Default", "Gamepad"…). */
val title: String? get() = mappings?.string("title")?.let { t ->
if (t.startsWith("#")) mappings.child("localization")?.child("english")?.string("title") ?: "Default" else t
}
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
val base = available[presetOf(actionSet)] ?: available.values.firstOrNull() ?: DEFAULT_SET
return layers.fold(base) { set, layer -> set + (available[presetOf(layer)] ?: emptySet()) } + ALWAYS
}
}
/** Big Picture's own config uses Steam's basic UI bindings: the default set. */
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
/** The layout entry for the action set the device was told about (the client counts from 1,
* 0 for an app with none). */
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
// ---- Where Steam keeps them ----
/** A game's name from its app manifest, when it is installed. */
fun gameName(context: Context, appId: Int): String? = try {
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
(kv.child("AppState") ?: kv).string("name")
} catch (_: Exception) {
null
}
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun configDir(context: Context): File? {
val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs")
val account = accountId(context)
if (account != null) File(base, "$account/config").takeIf { it.isDirectory }?.let { return it }
return base.listFiles()?.filter { it.name.all(Char::isDigit) && File(it, "config").isDirectory }
?.maxByOrNull { it.lastModified() }?.let { File(it, "config") }
}
/** The signed-in account (loginusers.vdf's MostRecent) as a 32-bit account id. */
private fun accountId(context: Context): Long? = try {
val users = KeyValues.parse(File(steamRoot(context), "config/loginusers.vdf").readText())
val node = users.child("users")
val entries = node?.entries?.mapNotNull { (k, v) -> (v as? KeyValues.Node)?.let { k to it } }.orEmpty()
val chosen = entries.firstOrNull { it.second.string("MostRecent") == "1" } ?: entries.firstOrNull()
chosen?.first?.toLongOrNull()?.minus(76561197960265728L)
} catch (_: Exception) {
null
}
/** The config the client has for app [appId] on the touch controller. Blocking: reads files. */
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
// The app's own saved config is what the client loads when there is one (its configset
// entry); otherwise what it logged loading - a template, an official or a workshop config.
val file = dir?.let { autosaveConfig(it, appId) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
if (file == null) {
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
return Config(null, null, null, emptyMap())
}
return try {
val text = file.readText()
val kv = KeyValues.parse(text.removePrefix(""))
val mappings = kv.child("controller_mappings") ?: kv
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
Config(file, text, layouts, availability(mappings), mappings)
} catch (e: Exception) {
Log.w(TAG, "steam touch: ${file.path} unreadable: $e")
Config(file, null, null, emptyMap())
}
}
private fun isTouchConfig(file: File) = file.isFile &&
file.bufferedReader().use { r -> generateSequence { r.readLine() }.take(40).any { it.contains("controller_mobile_touch") } }
/** From the client's console log: the last config it loaded for [appId] on the touch controller
* (`Loaded Config for ... App ID <n>, Controller <i>: <path>`, where controller i reported
* type 43). Guest paths come back as the app's own. */
private fun loggedConfig(context: Context, appId: Int): File? = try {
val log = File(steamRoot(context), "logs/console_log.txt")
val tail = RandomAccessFile(log, "r").use { raf ->
val start = maxOf(0L, raf.length() - 2_000_000L)
raf.seek(start)
val bytes = ByteArray((raf.length() - start).toInt())
raf.readFully(bytes)
String(bytes)
}
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
if (touchIndex == null) null else {
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
}
} catch (_: Exception) {
null
}
private fun autosaveConfig(dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
} catch (_: Exception) {
null
}
/** The app's entry in configset_controller_mobile_touch.vdf. */
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
}?.takeIf { it.isFile }
} catch (_: Exception) {
null
}
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
// ---- What a config binds ----
/** Per preset, the controls its active sources bind - what Steam Link shows (UpdateElementAvailability). */
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
val groups = mappings.children("group").associateBy({ it.string("id") ?: "" }) { group ->
group.child("inputs")?.entries?.filter { (_, v) -> (v as? KeyValues.Node)?.let(::hasBinding) == true }
?.map { it.first }?.toSet().orEmpty()
}
return mappings.children("preset").associate { preset ->
val id = preset.string("id")?.toIntOrNull() ?: 0
val set = mutableSetOf<Int>()
preset.child("group_source_bindings")?.entries?.forEach { (groupId, value) ->
val parts = (value as? String)?.split(' ') ?: return@forEach
if (parts.getOrNull(1) != "active" || parts.contains("modeshift")) return@forEach
val bound = groups[groupId].orEmpty()
when (parts[0]) {
"button_diamond" -> mapOf("button_a" to A, "button_b" to B, "button_x" to X, "button_y" to Y)
.forEach { (input, element) -> if (input in bound) set += element }
"dpad" -> set += DPAD
"joystick" -> { set += JOYSTICK_LEFT; if ("click" in bound) set += JOYSTICK_LEFT_BUTTON }
"right_joystick" -> { set += JOYSTICK_RIGHT; if ("click" in bound) set += JOYSTICK_RIGHT_BUTTON }
"left_trigger" -> set += TRIGGER_LEFT
"right_trigger" -> set += TRIGGER_RIGHT
"left_trackpad" -> set += TRACKPAD_LEFT
"right_trackpad" -> set += TRACKPAD_RIGHT
"center_trackpad" -> set += TRACKPAD_CENTER
"switch" -> bound.forEach { input ->
when (input) {
"button_escape" -> set += START
"button_menu" -> set += SELECT
"left_bumper" -> set += BUMPER_LEFT
"right_bumper" -> set += BUMPER_RIGHT
"button_macro_1finger" -> set += MACRO_1_FINGER
"button_macro_2finger" -> set += MACRO_2_FINGER
else -> Regex("button_macro(\\d)").matchEntire(input)?.let { set += MACRO_0 + it.groupValues[1].toInt() }
}
}
}
}
id to set.toSet()
}
}
private fun hasBinding(input: KeyValues.Node): Boolean =
input.child("activators")?.entries?.any { (_, activator) ->
(activator as? KeyValues.Node)?.child("bindings")?.entries?.any { (k, v) -> k == "binding" && (v as? String)?.isNotBlank() == true } == true
} == true
// ---- Steam Link's defaults ----
/** Where Steam Link puts a control with no layout (CVirtualController::BInitializeDefaultElement,
* on a 1280x720 screen), and the same scheme for the controls its table leaves out, made room
* for when a config binds sticks. */
fun defaultElement(type: Int, available: Set<Int>): Element? {
val leftStick = JOYSTICK_LEFT in available
val rightStick = JOYSTICK_RIGHT in available
val faceY = if (rightStick) -0.30f else 0f
val p: Pair<Float, Float> = when (type) {
THUMB -> 75f / 1280 to 75f / 720
STEAM -> 636f / 1280 to 75f / 720
SELECT -> 516f / 1280 to 75f / 720
START -> 756f / 1280 to 75f / 720
KEYBOARD -> 1205f / 1280 to 75f / 720
PASTE -> 1115f / 1280 to 75f / 720
DPAD -> if (leftStick) 0.147f to 0.47f else 200f / 1280 to 525f / 720
A -> 1083f / 1280 to 607f / 720 + faceY
B -> 1163f / 1280 to 527f / 720 + faceY
X -> 1003f / 1280 to 527f / 720 + faceY
Y -> 1083f / 1280 to 447f / 720 + faceY
JOYSTICK_LEFT -> 0.137f to 0.76f
JOYSTICK_RIGHT -> 0.85f to 0.76f
JOYSTICK_LEFT_BUTTON -> 0.29f to 0.88f
JOYSTICK_RIGHT_BUTTON -> 0.71f to 0.88f
BUMPER_LEFT -> 0.06f to 0.205f
TRIGGER_LEFT -> 0.16f to 0.205f
BUMPER_RIGHT -> 0.94f to 0.205f
TRIGGER_RIGHT -> 0.84f to 0.205f
TRACKPAD_LEFT -> 0.3f to 0.55f
TRACKPAD_CENTER -> 0.5f to 0.55f
TRACKPAD_RIGHT -> 0.7f to 0.55f
MACRO_1_FINGER -> 0.42f to 0.92f
MACRO_2_FINGER -> 0.58f to 0.92f
in MACRO_0..MACRO_0 + 7 -> (0.3f + 0.057f * (type - MACRO_0)) to 0.22f
else -> return null
}
return Element(type, true, p.first, p.second)
}
/** The controls to draw: the layout's own where it has one, Steam Link's defaults otherwise,
* only those the config binds. */
fun elementsFor(config: Config, actionSet: Int, layers: List<Int>): List<Element> {
val available = config.availableFor(actionSet, layers)
val layout = config.layouts?.forActionSet(layoutIdOf(actionSet))
val placed = layout?.elements?.associateBy { it.type }.orEmpty()
val optional = OPTIONAL.filter { placed[it]?.visible == true && it !in available }
return (available + optional).mapNotNull { type ->
val own = placed[type]
when {
own != null && !own.visible -> null
own != null -> own
else -> defaultElement(type, available)
}
}.sortedBy { it.type }
}
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
// ---- Saving ----
/**
* Saves [elements] as the layout of [actionSet] in app [appId]'s touch config: into the app's
* autosave config (`<appid>/controller_mobile_touch.vdf`, made from the config in use when the
* app has none) and selected for the app in the configset, as the client saves a layout Steam
* Link sends it. Returns the file written, or null.
*/
fun saveLayout(
context: Context,
appId: Int,
config: Config,
actionSet: Int,
elements: List<Element>,
color: FloatArray? = null,
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
): File? = saveLayouts(context, appId, config, mapOf(layoutIdOf(actionSet) to elements), color?.let { c -> setOf(layoutIdOf(actionSet)).associateWith { c } } ?: emptyMap(), bindings)
/**
* Saves the layouts of several action sets (by layout id) at once, with their colours, any
* binding edits and the per-game [options], into the game's autosave touch config.
*/
fun saveLayouts(
context: Context,
appId: Int,
config: Config,
sets: Map<Int, List<Element>>,
colors: Map<Int, FloatArray> = emptyMap(),
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
options: Options? = null,
): File? {
val dir = configDir(context) ?: return null
// Icons, labels and colours live in the binding strings (SteamTouchBindings), as the
// configurator writes them.
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
val written = sets.map { (id, elements) ->
Layout(id, elements, colors[id] ?: old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f), old?.forActionSet(id)?.version)
}
val rest = old?.rest ?: byteArrayOf(0x10, 0x02) // input_mode = controller
val layouts = Layouts((keep + written).sortedBy { it.actionSet }, options?.encodeInto(rest) ?: rest)
val hex = bytesToHex(encodeLayouts(layouts))
val line = "\t\"touch_layout\"\t\t\"$hex\""
val existing = Regex("""\n\s*"touch_layout"\s+"[0-9a-fA-F]*"""")
val text = if (existing.containsMatchIn(source)) source.replace(existing, "\n$line")
else source.replaceFirst(Regex("""("controller_type"\s+"controller_mobile_touch"[^\n]*)"""), "$1\n$line")
if (!text.contains("touch_layout")) return null
target.parentFile?.mkdirs()
val tmp = File(target.path + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
/** Points the app's configset entry at its autosave config. */
private fun selectAutosave(dir: File, appId: Int) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
}
// ---- touch_layout: CVirtualControllerLayouts ----
fun decodeLayouts(bytes: ByteArray): Layouts {
val layouts = mutableListOf<Layout>()
val rest = ByteArrayOutputStream()
val p = Proto(bytes)
while (p.more()) {
val start = p.pos
val (field, wire) = p.key()
if (field == 1 && wire == 2) layouts += decodeLayout(p.bytes())
else { p.skip(wire); rest.write(bytes, start, p.pos - start) }
}
return Layouts(layouts, rest.toByteArray())
}
private fun decodeLayout(bytes: ByteArray): Layout {
var actionSet = 0
var version: Int? = null
var color: FloatArray? = null
val elements = mutableListOf<Element>()
val p = Proto(bytes)
while (p.more()) {
val (field, wire) = p.key()
when {
field == 1 && wire == 0 -> version = p.varint().toInt()
field == 2 && wire == 0 -> actionSet = p.varint().toInt()
field == 4 && wire == 2 -> elements += decodeElement(p.bytes())
field == 5 && wire == 2 -> color = decodeColor(p.bytes())
else -> p.skip(wire)
}
}
return Layout(actionSet, elements, color, version)
}
private fun decodeElement(bytes: ByteArray): Element {
var type = -1
var visible = false
var x = 0f
var y = 0f
var sx = 1f
var sy = 1f
val p = Proto(bytes)
while (p.more()) {
val (field, wire) = p.key()
when {
field == 1 && wire == 0 -> type = p.varint().toInt()
field == 2 && wire == 0 -> visible = p.varint() != 0L
field == 3 && wire == 5 -> x = p.float()
field == 4 && wire == 5 -> y = p.float()
field == 5 && wire == 5 -> sx = p.float()
field == 6 && wire == 5 -> sy = p.float()
else -> p.skip(wire)
}
}
return Element(type, visible, x, y, sx, sy)
}
private fun decodeColor(bytes: ByteArray): FloatArray {
val c = floatArrayOf(1f, 1f, 1f, 1f)
val p = Proto(bytes)
while (p.more()) {
val (field, wire) = p.key()
if (field in 1..4 && wire == 5) c[field - 1] = p.float() else p.skip(wire)
}
return c
}
fun encodeLayouts(layouts: Layouts): ByteArray {
val out = ByteArrayOutputStream()
layouts.layouts.forEach { layout ->
val l = ByteArrayOutputStream()
layout.version?.let { tag(l, 1, 0); varint(l, it.toLong()) }
tag(l, 2, 0); varint(l, layout.actionSet.toLong())
layout.elements.forEach { e ->
val m = ByteArrayOutputStream()
tag(m, 1, 0); varint(m, e.type.toLong())
tag(m, 2, 0); varint(m, if (e.visible) 1 else 0)
tag(m, 3, 5); fixed(m, e.x)
tag(m, 4, 5); fixed(m, e.y)
tag(m, 5, 5); fixed(m, e.xScale)
tag(m, 6, 5); fixed(m, e.yScale)
tag(l, 4, 2); bytes(l, m.toByteArray())
}
layout.color?.let { c ->
val m = ByteArrayOutputStream()
for (i in 0 until 4) { tag(m, i + 1, 5); fixed(m, c[i]) }
tag(l, 5, 2); bytes(l, m.toByteArray())
}
tag(out, 1, 2); bytes(out, l.toByteArray())
}
out.write(layouts.rest)
return out.toByteArray()
}
private fun tag(out: ByteArrayOutputStream, field: Int, wire: Int) = varint(out, (field shl 3 or wire).toLong())
private fun varint(out: ByteArrayOutputStream, value: Long) {
var v = value
while (true) {
if (v and 0x7fL.inv() == 0L) { out.write(v.toInt()); return }
out.write((v and 0x7f or 0x80).toInt())
v = v ushr 7
}
}
private fun fixed(out: ByteArrayOutputStream, f: Float) {
val bits = java.lang.Float.floatToIntBits(f)
for (i in 0 until 4) out.write(bits ushr (8 * i) and 0xff)
}
private fun bytes(out: ByteArrayOutputStream, b: ByteArray) { varint(out, b.size.toLong()); out.write(b) }
private class Proto(val data: ByteArray) {
var pos = 0
fun varint(): Long {
var shift = 0
var result = 0L
while (pos < data.size) {
val b = data[pos++].toInt() and 0xff
result = result or ((b and 0x7f).toLong() shl shift)
if (b and 0x80 == 0) break
shift += 7
}
return result
}
fun float(): Float {
var bits = 0
for (i in 0 until 4) bits = bits or ((data[pos + i].toInt() and 0xff) shl (8 * i))
pos += 4
return java.lang.Float.intBitsToFloat(bits)
}
fun bytes(): ByteArray {
val n = varint().toInt()
return data.copyOfRange(pos, pos + n).also { pos += n }
}
fun skip(wire: Int) {
when (wire) {
0 -> varint()
1 -> pos += 8
2 -> pos += varint().toInt()
5 -> pos += 4
else -> pos = data.size
}
}
fun more() = pos < data.size
fun key(): Pair<Int, Int> = varint().toInt().let { (it ushr 3) to (it and 7) }
}
fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
fun bytesToHex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
/** Valve's KeyValues text format, as far as controller configs use it. */
class KeyValues private constructor() {
class Node(val entries: MutableList<Pair<String, Any>> = mutableListOf()) {
fun child(key: String): Node? = entries.firstOrNull { it.first.equals(key, true) && it.second is Node }?.second as Node?
fun children(key: String): List<Node> = entries.filter { it.first.equals(key, true) && it.second is Node }.map { it.second as Node }
fun string(key: String): String? = entries.firstOrNull { it.first.equals(key, true) && it.second is String }?.second as String?
}
companion object {
fun parse(text: String): Node {
val tokens = tokenize(text)
var i = 0
fun node(): Node {
val n = Node()
while (i < tokens.size) {
val t = tokens[i++]
if (t == "}" ) return n
if (t == "{") continue
if (i >= tokens.size) break
val next = tokens[i]
if (next == "{") { i++; n.entries += t to node() } else { i++; n.entries += t to next }
}
return n
}
return node()
}
private fun tokenize(text: String): List<String> {
val out = mutableListOf<String>()
var i = 0
while (i < text.length) {
val c = text[i]
when {
c.isWhitespace() -> i++
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
c == '{' || c == '}' -> { out += c.toString(); i++ }
c == '"' -> {
val sb = StringBuilder()
i++
while (i < text.length && text[i] != '"') {
if (text[i] == '\\' && i + 1 < text.length) {
i++
sb.append(when (text[i]) { 'n' -> '\n'; 't' -> '\t'; else -> text[i] })
} else sb.append(text[i])
i++
}
i++
out += sb.toString()
}
c == '[' -> { while (i < text.length && text[i] != ']') i++; i++ }
else -> {
val start = i
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
out += text.substring(start, i)
}
}
}
return out
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
package com.droiddeck.launcher.input
import android.util.Log
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteOrder
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/**
* Steam's touch controller, as the app's side of it.
*
* libfakeinput offers the Steam client a HID device with the ids of Steam Link's touch controls
* (0000:11fb), and the client builds its own touch controller for it: Steam Input's Mobile Touch
* type, with the game's touch config, action sets, community configs and cloud sync all Steam's
* (docs/development/steam-touch-controller.md). This class is the file the two share
* (fakeinput_steam.cpp: TouchRingFile): the app writes the device's 40-byte input report and
* whether it is plugged in; libfakeinput writes back what the client told the device - the app and
* action set it is on, its layers, rumble.
*/
class SteamTouchDevice private constructor(private val buffer: ByteBuffer) {
/** What the client last told the device. */
data class State(
val opened: Int,
val appId: Int,
val actionSet: Int,
val layers: List<Int>,
val actionSeq: Int,
)
// The input report (Steam Link's CVirtualController, docs/development/steam-touch-controller.md):
// bytes 0-7 buttons, 8-15 sticks, 16-27 trackpads, 28-33 accelerometer, 34-39 gyro.
private val report = ByteArray(REPORT_BYTES)
private var reportSeq = 0L
/** The controls part of the report: button bits, sticks (LX, LY, RX, RY, already in the report's
* units) and trackpads (centre, left, right: x, y each). */
@Synchronized
fun setControls(buttons: Long, sticks: ShortArray, pads: ShortArray) {
val next = report.copyOf()
for (i in 0 until 8) next[i] = (buttons ushr (8 * i)).toByte()
for (i in 0 until 4) putShort(next, 8 + 2 * i, sticks[i])
for (i in 0 until 6) putShort(next, 16 + 2 * i, pads[i])
publish(next)
}
/** Motion in the Deck's axes and units (PadMotion), which the touch report shares. */
@Synchronized
fun setMotion(accel: ShortArray, gyro: ShortArray) {
// Sent only while the client asks for it (setting report 0x30, which Steam Link reads as
// "gyro on") and the player has not turned motion off - as Steam Link does.
if (!plugged || !gyroRequested || !motionEnabled) return
val next = report.copyOf()
for (i in 0 until 3) {
// Accelerometer: the Deck's 1 g = 16384 is the touch report's ±2 g = ±32767 already.
putShort(next, 28 + 2 * i, accel[i])
// Gyro: the Deck's 2000 °/s = 32768; the touch report's full scale is 1000 °/s
// (Steam Link: rad/s × 180/π / 1000 × 32767).
putShort(next, 34 + 2 * i, (gyro[i] * 2).coerceIn(-32767, 32767).toShort())
}
publish(next)
}
/** Whether the client has asked the device for motion (setting report 0x30 non-zero). */
val gyroRequested get() = buffer.getInt(OFF_SETTING30) != 0
/** The player's motion switch (Steam Link's gyroscope option). */
@Volatile var motionEnabled = true
set(value) {
field = value
if (!value) synchronized(this) {
val next = report.copyOf()
for (i in 28 until 40) next[i] = 0
publish(next)
}
}
private fun putShort(into: ByteArray, at: Int, value: Short) {
into[at] = value.toInt().toByte()
into[at + 1] = (value.toInt() shr 8).toByte()
}
/** Writes the report under the seqlock libfakeinput reads it with. */
private fun publish(next: ByteArray) {
if (next.contentEquals(report)) return
next.copyInto(report)
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
for (i in 0 until REPORT_BYTES) buffer.put(OFF_REPORT + i, report[i])
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
}
var plugged: Boolean
get() = buffer.getInt(OFF_PLUGGED) != 0
set(value) {
if (value == plugged) return
if (!value) synchronized(this) { publish(ByteArray(REPORT_BYTES)) }
buffer.putInt(OFF_PLUGGED, if (value) 1 else 0)
Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged")
}
fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100))
fun state(): State {
repeat(8) {
val seq = buffer.getLong(OFF_STATE_SEQ)
if (seq and 1L != 0L) return@repeat
val count = buffer.getInt(OFF_LAYER_COUNT).coerceIn(0, 8)
val read = State(
opened = buffer.getInt(OFF_OPENED),
appId = buffer.getInt(OFF_APPID),
actionSet = buffer.getInt(OFF_ACTION_SET),
layers = (0 until count).map { buffer.getInt(OFF_LAYERS + 4 * it) },
actionSeq = buffer.getInt(OFF_ACTION_SEQ),
)
if (seq == buffer.getLong(OFF_STATE_SEQ)) return read
}
return State(0, 0, 0, emptyList(), -1)
}
companion object {
private const val TAG = "SteamTouch"
const val REPORT_BYTES = 40
private const val SIZE = 256
private const val MAGIC = 0x31484354 // TCH1
private const val OFF_REPORT_SEQ = 8
private const val OFF_REPORT = 16
private const val OFF_PLUGGED = 56
private const val OFF_BATTERY = 60
private const val OFF_STATE_SEQ = 64
private const val OFF_OPENED = 72
private const val OFF_APPID = 76
private const val OFF_ACTION_SET = 80
private const val OFF_LAYER_COUNT = 84
private const val OFF_LAYERS = 88
private const val OFF_SETTING30 = 132
private const val OFF_ACTION_SEQ = 136
@Volatile
var current: SteamTouchDevice? = null
private set
/** The shared file in the session's pad directory, made fresh each session. */
fun ringFile(fakeInputDir: File) = File(fakeInputDir, "touchctl")
/** Creates the file for a new session (unplugged) and maps it. */
fun prepare(fakeInputDir: File): File? = try {
val file = ringFile(fakeInputDir)
file.delete()
RandomAccessFile(file, "rw").use { raf ->
raf.setLength(SIZE.toLong())
val map = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()).order(ByteOrder.LITTLE_ENDIAN)
map.putInt(4, 1)
map.putInt(0, MAGIC)
current = SteamTouchDevice(map)
}
file.setReadable(true, false)
file.setWritable(true, false)
file
} catch (e: Exception) {
Log.w(TAG, "steam touch: no shared file: $e")
current = null
null
}
fun release() {
current?.plugged = false
current = null
}
}
}
@@ -0,0 +1,274 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.text.InputType
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.EditText
import android.widget.GridView
import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import java.util.concurrent.Executors
/**
* Picks a touch control's icon, label and colours the way Steam's configurator does: an icon from
* the game's own `TouchMenuIcons` and Steam's binding icon library, a foreground and a background
* from Steam's palette. [onPicked] gets the binding with the new fields (the action untouched).
*/
class SteamTouchIconPicker(
private val context: Context,
private val host: ViewGroup,
private val appId: Int,
private val title: String,
private val current: SteamTouchBindings.Binding,
private val onPicked: (SteamTouchBindings.Binding) -> Unit,
) {
private val main = Handler(Looper.getMainLooper())
private val loader = Executors.newFixedThreadPool(2)
private var icon = current.icon
private var foreground = current.foreground.ifEmpty { SteamTouchBindings.DEFAULT_FOREGROUND }
private var background = current.background.ifEmpty { SteamTouchBindings.DEFAULT_BACKGROUND }
private val density = context.resources.displayMetrics.density
private fun dp(v: Int) = (v * density).toInt()
fun show() {
// Side by side, for a landscape screen: what is picked on the left, the icons on the right.
val root = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; setPadding(dp(16), dp(8), dp(16), 0) }
val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
val label = EditText(context).apply {
hint = "Label (optional)"
setText(current.label)
inputType = InputType.TYPE_CLASS_TEXT
setSingleLine()
}
left.addView(label)
val preview = ImageView(context)
val names = SteamTouchBindings.iconNames(context, appId)
val grid = GridView(context).apply {
numColumns = GridView.AUTO_FIT
columnWidth = dp(56)
stretchMode = GridView.STRETCH_COLUMN_WIDTH
verticalSpacing = dp(6)
horizontalSpacing = dp(6)
}
val adapter = IconAdapter(names)
grid.adapter = adapter
grid.setOnItemClickListener { _, _, position, _ ->
icon = names[position]
adapter.notifyDataSetChanged()
updatePreview(preview)
}
left.addView(LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(8), 0, 0)
addView(preview, LinearLayout.LayoutParams(dp(56), dp(56)))
addView(TextView(context).apply {
text = if (names.isEmpty()) "Steam's icons were not found" else "${names.size} icons from Steam"
setPadding(dp(12), 0, 0, 0)
})
})
left.addView(paletteRow("Icon colour", { foreground }) { foreground = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
left.addView(paletteRow("Button colour", { background }) { background = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
root.addView(left, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
root.addView(grid, LinearLayout.LayoutParams(0, dp(250), 1.2f).apply { marginStart = dp(16) })
updatePreview(preview)
showPanel(host, title, root, listOf(
"No icon" to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
},
"Cancel" to {},
"Use" to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
},
)) { loader.shutdown() }
}
private fun paletteRow(name: String, value: () -> String, onPick: (String) -> Unit): View {
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
val swatches = mutableListOf<View>()
fun refresh() = swatches.forEachIndexed { i, v ->
(v.background as GradientDrawable).setStroke(dp(if (SteamTouchBindings.PALETTE[i].equals(value(), true)) 3 else 1),
if (SteamTouchBindings.PALETTE[i].equals(value(), true)) Color.rgb(102, 192, 244) else Color.GRAY)
}
SteamTouchBindings.PALETTE.forEach { hex ->
val swatch = View(context).apply {
background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.parseColor(hex)) }
setOnClickListener { onPick(hex); refresh() }
}
swatches += swatch
row.addView(swatch, LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
}
refresh()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dp(8), 0, 0)
addView(TextView(context).apply { text = name })
addView(HorizontalScrollView(context).apply { addView(row) })
}
}
private fun updatePreview(view: ImageView) {
if (icon.isEmpty()) { view.setImageDrawable(null); view.background = null; return }
loader.execute {
val bitmap = SteamTouchBindings.icon(context, appId, icon, 128)?.let { render(it, dp(56)) }
main.post { view.setImageBitmap(bitmap) }
}
}
/** The icon as Steam draws it: on a disc of the background colour, filled with the foreground
* through its own alpha. */
private fun render(icon: Bitmap, size: Int): Bitmap {
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(out)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
paint.color = Color.parseColor(background)
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
drawSteamIcon(canvas, icon, android.graphics.RectF(size * 0.18f, size * 0.18f, size * 0.82f, size * 0.82f), Color.parseColor(foreground), paint)
return out
}
private inner class IconAdapter(val names: List<String>) : BaseAdapter() {
private val loading = HashSet<String>()
private var refreshQueued = false
/** Loads an icon in the background; the grid redraws from the cache once some have come in. */
private fun load(name: String) {
if (!loading.add(name)) return
loader.execute {
SteamTouchBindings.icon(context, appId, name, 96)
main.post {
loading.remove(name)
if (refreshQueued) return@post
refreshQueued = true
main.postDelayed({ refreshQueued = false; notifyDataSetChanged() }, 120)
}
}
}
override fun getCount() = names.size
override fun getItem(position: Int) = names[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = (convertView as? ImageView) ?: ImageView(context).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56))
setPadding(dp(8), dp(8), dp(8), dp(8))
}
val name = names[position]
view.tag = name
view.background = GradientDrawable().apply {
cornerRadius = dp(8).toFloat()
setColor(Color.parseColor(background))
if (name == icon) setStroke(dp(3), Color.rgb(102, 192, 244))
}
view.colorFilter = null
val cached = SteamTouchBindings.cachedIcon(appId, name, 96)
if (cached != null) {
val t = tinted(name, cached)
if ((view.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap !== t) view.setImageBitmap(t)
} else {
view.setImageDrawable(null)
load(name)
}
return view
}
// One thumbnail per icon and colour, reused when the grid rebinds.
private val thumbs = android.util.LruCache<String, Bitmap>(64)
private fun tinted(name: String, icon: Bitmap): Bitmap {
val key = "$name|$foreground"
thumbs.get(key)?.let { return it }
val out = Bitmap.createBitmap(icon.width, icon.height, Bitmap.Config.ARGB_8888)
drawSteamIcon(Canvas(out), icon, android.graphics.RectF(0f, 0f, icon.width.toFloat(), icon.height.toFloat()),
Color.parseColor(foreground), Paint(Paint.FILTER_BITMAP_FLAG))
thumbs.put(key, out)
return out
}
}
companion object {
/**
* A panel over the session, in the session's own window: a dialog's window of its own left
* new bitmaps and layers undrawn in the session window afterwards on the Thor.
*/
fun showPanel(host: ViewGroup, title: String, content: View, buttons: List<Pair<String, () -> Unit>>, onClose: () -> Unit = {}): () -> Unit {
val context = host.context
val density = context.resources.displayMetrics.density
fun dp(v: Int) = (v * density).toInt()
val scrim = android.widget.FrameLayout(context).apply {
setBackgroundColor(Color.argb(140, 0, 0, 0))
isClickable = true
isFocusable = true
}
// DroidDeck's panel: dark surface, rounded, a hairline border.
val card = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = GradientDrawable().apply {
cornerRadius = dp(18).toFloat(); setColor(Color.rgb(18, 20, 23)); setStroke(dp(1), Color.rgb(38, 42, 49))
}
setPadding(dp(12), dp(14), dp(12), dp(10))
isClickable = true
}
card.addView(TextView(context).apply {
text = title
textSize = 15f
setTypeface(android.graphics.Typeface.DEFAULT_BOLD)
gravity = Gravity.CENTER
setTextColor(Color.WHITE)
setPadding(dp(16), 0, dp(16), dp(4))
})
card.addView(content)
val row = LinearLayout(context).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), 0) }
fun close() {
(scrim.parent as? ViewGroup)?.removeView(scrim)
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(host.windowToken, 0)
onClose()
}
buttons.forEachIndexed { i, (name, action) ->
row.addView(TextView(context).apply {
text = name
gravity = Gravity.CENTER
textSize = 14f
setTextColor(Color.WHITE)
val primary = i == buttons.size - 1
setTextColor(if (primary) Color.rgb(3, 17, 31) else Color.rgb(242, 244, 247))
background = GradientDrawable().apply {
cornerRadius = dp(12).toFloat()
if (primary) setColor(Color.rgb(26, 159, 255)) else { setColor(Color.rgb(26, 29, 34)); setStroke(dp(1), Color.rgb(52, 58, 67)) }
}
setPadding(dp(20), dp(10), dp(20), dp(10))
setOnClickListener { close(); action() }
}, LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginStart = dp(6); marginEnd = dp(6) })
}
card.addView(row)
val width = minOf(dp(640), (context.resources.displayMetrics.widthPixels * 0.9f).toInt())
scrim.addView(card, android.widget.FrameLayout.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
scrim.setOnClickListener { close() }
host.addView(scrim, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
return { if (scrim.parent != null) close() }
}
/** Steam's binding icon look (steamui: the icon multiplied with its foreground colour,
* background-blend-mode: multiply, keeping the icon's own alpha). */
fun drawSteamIcon(canvas: Canvas, icon: Bitmap, into: android.graphics.RectF, foreground: Int, paint: Paint) {
val tint = Paint(paint).apply { colorFilter = PorterDuffColorFilter(foreground, PorterDuff.Mode.MULTIPLY) }
canvas.drawBitmap(icon, null, into, tint)
}
}
}
@@ -256,6 +256,20 @@ object AppImageManager {
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
/** The desktop's import of [key]; older ones of the image at [path], since replaced, are removed. */
fun desktopImport(context: Context, key: String, path: String): String? {
var found: String? = null
root(context).listFiles()?.forEach { dir ->
val asked = FileUtils.readString(File(dir, UserApps.DESKTOP_IMPORT))?.lines() ?: return@forEach
if (asked.firstOrNull() == key) {
if (File(dir, "app").isDirectory) found = dir.name
} else if (asked.getOrNull(1) == path) {
remove(context, dir.name)
}
}
return found
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
@@ -20,12 +20,12 @@ import java.io.File
*/
object DesktopCatalog {
private const val TAG = "DesktopCatalog"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
/**
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
* In a catalog of its own so it never shows among the packages a user picks from.
*/
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
const val PROTON_SEED_ID = "proton-arm64"
class Entry(
@@ -95,12 +95,26 @@ object DesktopCatalog {
// The desktop package installed to the end: KWin is there and the marker install() writes once
// the whole package is extracted. An install cut short (the app killed, storage full) leaves
// KWin without the marker, and the next desktop installs it again. The launcher is staged by
// KWin without the marker, and the next desktop installs it again. A newer pinned package also
// replaces the installed one, keeping the user's home and settings. The launcher is staged by
// the app at every session, so it cannot tell. SessionFiles uses the same test.
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
fun desktopInstalled(root: File): Boolean =
File(root, "usr/bin/kwin_wayland").isFile && File(root, ".droiddeck-pkg-$DESKTOP_ID").isFile
!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
fun desktopPresent(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
}
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
DesktopRemoval.cleanLegacy(context, listener)
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
@@ -141,7 +155,13 @@ object DesktopCatalog {
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
@@ -0,0 +1,97 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.attribute.BasicFileAttributes
import java.security.MessageDigest
/** Removes only unchanged, recorded package files. Never visits home or follows a symlink. */
internal object DesktopFileRemoval {
data class Entry(val kind: String, val size: Long, val identity: String, val path: String) {
companion object {
fun parse(line: String): Entry {
val parts = line.split('\t', limit = 4)
require(parts.size == 4 && parts[0] in setOf("F", "L")) { "Invalid removal inventory" }
return Entry(parts[0], parts[1].toLong(), parts[2], parts[3])
}
}
}
data class Result(val removed: Int, val bytes: Long, val kept: Int)
fun remove(root: File, entries: Iterable<Entry>, protected: Set<String>, progress: (Int) -> Unit = {}): Result {
var removed = 0
var bytes = 0L
var kept = 0
val buffer = ByteArray(65536)
val total = (entries as? Collection<*>)?.size ?: 0
val directories = mutableSetOf<File>()
for ((index, entry) in entries.withIndex()) {
val file = safeFile(root, entry.path)
if (file == null || entry.path in protected) { kept++; continue }
val attrs = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) }
catch (_: java.nio.file.NoSuchFileException) { continue }
val matches = when (entry.kind) {
"L" -> attrs.isSymbolicLink && Files.readSymbolicLink(file.toPath()).toString() == entry.identity
else -> attrs.isRegularFile && attrs.size() == entry.size && sha(file, buffer) == entry.identity
}
if (!matches) { kept++; continue }
Files.delete(file.toPath()) // A failure propagates; the pending marker makes retry possible.
removed++
bytes += if (attrs.isRegularFile) attrs.size() else 0
var parent = file.parentFile
while (parent != root && parent != null) { directories.add(parent); parent = parent.parentFile }
if (index % 200 == 0) progress(if (total > 0) index * 99 / total else -1)
}
// Only now-empty parents of removed files, never a recursive delete of a package directory.
for (dir in directories.sortedByDescending { it.path.length }) {
if (dir.list()?.isEmpty() == true && !dir.delete()) throw IOException("Cannot remove $dir")
}
progress(99)
return Result(removed, bytes, kept)
}
internal fun safeFile(root: File, name: String): File? {
if (Files.isSymbolicLink(root.toPath())) return null
val parts = name.split('/')
if (parts.firstOrNull() !in setOf("usr", "etc") || parts.any { it.isEmpty() || it == "." || it == ".." }) return null
// Even an in-root symlink in a parent can redirect deletion into a user's data.
var parent = root
for (part in parts.dropLast(1)) {
parent = File(parent, part)
if (Files.isSymbolicLink(parent.toPath())) return null
}
return File(root, name)
}
internal fun sha(file: File, buffer: ByteArray = ByteArray(65536)): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
val digits = "0123456789abcdef"
return buildString(64) {
for (byte in digest.digest()) { val b = byte.toInt() and 255; append(digits[b ushr 4]); append(digits[b and 15]) }
}
}
/** The base runtime's pacman database is still present: desktop archives never replace it. */
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")
for (pkg in database.listFiles().orEmpty()) {
val files = File(pkg, "files")
if (!pkg.isDirectory || Files.isSymbolicLink(pkg.toPath()) || !files.isFile || Files.isSymbolicLink(files.toPath())) continue
var inFiles = false
files.forEachLine { line ->
when {
line == "%FILES%" -> inFiles = true
line.startsWith('%') -> inFiles = false
inFiles && line.isNotEmpty() && !line.endsWith('/') -> add(line)
}
}
}
if (isEmpty()) throw IOException("Runtime package inventory missing")
}
}
@@ -0,0 +1,77 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.util.zip.GZIPInputStream
/** Inventories are generated from pinned archives by tools/desktop-kde/removal-manifests.py. */
internal object DesktopRemoval {
const val PENDING = ".droiddeck-desktop-removing"
private const val CLEANING = ".droiddeck-lxqt-cleaning-v1"
private const val CLEANED = ".droiddeck-lxqt-cleaned-v1"
private fun lines(context: Context, name: String) =
GZIPInputStream(context.assets.open("desktop-removal/$name")).bufferedReader().use { it.readLines() }
private fun protected(context: Context, root: File) =
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
val root = LinuxRuntime.rootDir(context)
if (FileUtils.readString(File(root, CLEANED))?.trim() == "1") return
if (!File(root, CLEANING).exists() && !File(root, ".droiddeck-pkg-desktop").exists() && !File(root, "usr/bin/lxqt-session").exists()) return
listener?.onProgress(context.getString(R.string.desktop_cleaning_legacy), -1)
val keep = protected(context, root) + lines(context, "kde-keep.txt.gzip")
val pending = File(root, CLEANING)
FileUtils.writeString(pending, "1")
check(FileUtils.readString(pending)?.trim() == "1") { "Could not reserve LXQt cleanup" }
val result = GZIPInputStream(context.assets.open("desktop-removal/lxqt-r1.tsv.gzip")).bufferedReader().use { reader ->
DesktopFileRemoval.remove(root, reader.lineSequence().map(DesktopFileRemoval.Entry::parse).asIterable(), keep)
}
FileUtils.writeString(File(root, CLEANED), "1")
check(FileUtils.readString(File(root, CLEANED))?.trim() == "1") { "Could not finish LXQt cleanup" }
File(root, ".droiddeck-pkg-desktop").delete()
pending.delete()
Log.i("DesktopRemoval", "LXQt: $result")
}
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
val root = LinuxRuntime.rootDir(context)
val pending = File(root, PENDING)
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
val version = FileUtils.readString(pending)?.trim() ?: FileUtils.readString(marker)?.trim()
val versions = context.assets.open("desktop-removal/versions.txt").bufferedReader().use { it.readLines() }
check(version in versions) { context.getString(R.string.desktop_removal_unknown) }
// Validate all inventories and the runtime database before dropping the installed marker.
val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse)
val keep = protected(context, 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) }
// APK-staged desktop files are matched against the APK too; keep user-modified copies.
for ((asset, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
val target = DesktopFileRemoval.safeFile(root, path) ?: continue
if (!isRegularFile(target)) continue
val content = context.assets.open("linuxfs/$asset").use { it.readBytes() }
if (target.length() == content.size.toLong() && target.inputStream().use { it.readBytes() }.contentEquals(content)) {
if (!target.delete()) error("Could not remove $path")
}
}
if (!pending.delete()) error("Could not finish desktop removal")
Log.i("DesktopRemoval", "KDE: $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 fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)
}
@@ -41,7 +41,7 @@ public final class LinuxRuntimeInstaller {
/** Catalog row, beside the other component catalogs in winlator-contents. */
public static final String CATALOG_URL =
"https://raw.githubusercontent.com/The412Banner/winlator-contents/main/linuxfs.json";
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
private static final String VERSION_FILE = ".version";
@@ -85,6 +85,7 @@ public final class LinuxRuntimeInstaller {
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
boolean maintenance;
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
}
@@ -139,7 +140,7 @@ public final class LinuxRuntimeInstaller {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (running != null && running.removal) return false;
if (running != null && (running.removal || running.maintenance)) return false;
owner = running == null;
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
@@ -171,7 +172,7 @@ public final class LinuxRuntimeInstaller {
/** True while an install is running in this process, whoever started it. */
public static boolean isInstalling() {
synchronized (JOB_LOCK) {
return running != null && !running.removal;
return running != null && !running.removal && !running.maintenance;
}
}
@@ -183,6 +184,53 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) { return running != null && running.removal; }
}
public static boolean isMaintaining() {
synchronized (JOB_LOCK) { return running != null && running.maintenance; }
}
/** Reserve an in-place package operation with the same exclusion/progress as runtime installs. */
public static Maintenance beginMaintenance(Context context, String stage) {
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
synchronized (JOB_LOCK) {
if (running != null) return null;
removalError = null;
running = new Job(false, stage);
running.maintenance = true;
return new Maintenance(running);
}
}
public interface MaintenanceWork { String run(ProgressListener listener); }
public static final class Maintenance {
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Maintenance(Job job) { this.job = job; }
public boolean run(ProgressListener listener, MaintenanceWork work) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
removalError = work.run((stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
});
job.ok = removalError == null;
return job.ok;
} catch (Exception e) {
removalError = e.getMessage();
Log.e(TAG, "maintenance", e);
return false;
} finally {
synchronized (JOB_LOCK) { running = null; }
job.done.countDown();
if (listener != null) job.listeners.remove(listener);
}
}
}
public static String removalError() { return removalError; }
public static boolean hasRemovalPending(Context context) {
@@ -30,7 +30,7 @@ class RuntimeInstallService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
@@ -68,7 +68,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -32,6 +32,8 @@ object UserApps {
private const val TAG = "UserApps"
const val GUEST_DIR = "/opt/droiddeck-apps"
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
/** An app the desktop asked for (droiddeck-open): its key, then the path it was started from there. */
const val DESKTOP_IMPORT = "desktop-import"
private const val FLATPAK_OVERRIDES = ".flatpak"
private const val ICON_SIZE = 256
private const val ELF_SCAN_LIMIT = 4000
@@ -72,8 +74,8 @@ object UserApps {
class FlatpakBundle(val file: File) : Source()
}
/** [icon]: a picked file's path or a suggested icon's URL. */
class Request(val source: Source, val name: String?, val icon: String?)
/** [icon]: a picked file's path or a suggested icon's URL. [meta]: files written into the added app's folder. */
class Request(val source: Source, val name: String?, val icon: String?, val meta: Map<String, String> = emptyMap())
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
@@ -121,8 +123,8 @@ object UserApps {
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
return withIcon(context, request.icon) { icon ->
when (val s = request.source) {
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
@@ -197,6 +199,14 @@ object UserApps {
}.distinct()
}
/** The desktop's import of [key] as the session sees it: an added script's folder, or an AppImage's. */
fun desktopImport(context: Context, key: String, path: String): String? {
root(context).listFiles()?.firstOrNull { dir ->
File(dir, "entry").isFile && FileUtils.readString(File(dir, DESKTOP_IMPORT))?.lineSequence()?.firstOrNull() == key
}?.let { return "$GUEST_DIR/${it.name}" }
return AppImageManager.desktopImport(context, key, path)?.let { "${AppImageManager.GUEST_DIR}/$it" }
}
fun planScript(script: File): ScriptPlan {
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
var scanned = 0
@@ -209,7 +219,9 @@ object UserApps {
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
} catch (e: Exception) { false }
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
private fun addScript(
context: Context, script: File, name: String?, icon: File?, meta: Map<String, String>, onProgress: (String, Int) -> Unit,
): String? {
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
val plan = planScript(script)
val base = root(context).apply { mkdirs() }
@@ -232,6 +244,7 @@ object UserApps {
FileUtils.writeString(File(dir, "name"), title)
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
writeScriptEntry(context, dir)
return null
} catch (e: Exception) {
@@ -68,12 +68,19 @@ object GameStorage {
class Library(val host: File, val guest: String, val label: String)
/**
* Games folders an earlier build registered as Steam libraries that Steam has installed games
* into (an appmanifest in their steamapps/): those stay libraries, so the games are not lost.
* Any other Games folder is no longer one; the session's library list leaves it out and the
* runtime's droiddeck-steam-library forgets it.
*/
fun gamesFolderLibraries(context: Context): List<Library> {
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
val card = effective(context)?.let { canonical(File(it.path)) }
val sm = context.getSystemService(StorageManager::class.java)
return AddedGames.roots(context).mapNotNull { root ->
val host = canonical(root.host)
if (!hasInstalledGames(root.host)) return@mapNotNull null
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
@@ -83,6 +90,10 @@ object GameStorage {
}
}
/** Steam has installed a game into [root] as a library: a manifest in its steamapps/. */
internal fun hasInstalledGames(root: File): Boolean =
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
/** The label the client shows for a chosen folder: its last name, or the volume's. */
@@ -4,6 +4,7 @@ import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@@ -22,19 +23,21 @@ import kotlin.math.max
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
* values. This listens for those and plays the effect on the physical controller that last drove
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
* missing listener costs the guest nothing, and a bad packet is dropped.
* values. This listens for those and plays the effect on the physical controller playing in that
* slot (PadBridge.controllerForSlot) when Android exposes its motors - strong and weak separately
* where it has two - and otherwise on the device's own vibrator, with the stronger motor's
* strength: the on-screen pad, or a controller without rumble. Best effort both ways: a missing
* listener costs the guest nothing, and a bad packet is dropped.
*/
class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var phone: Motors? = null
/** Where the last effect went, so the next one (or a stop) can end it there. */
private var playing: Motors? = null
private var controllerId = PadBridge.NO_CONTROLLER
private var controller: Motors? = null
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
private val playing = HashMap<Int, Motors>()
/** Per slot, the last effect handed to the motors and when, to drop the repeats games stream. */
private val sent = HashMap<Int, Sent>()
/** Each controller's motors, looked up once; null when it has none. */
private val controllers = HashMap<Int, Motors?>()
private var preferences: SharedPreferences? = null
private var enabled = false
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
@@ -46,7 +49,7 @@ class RumbleComponent : SessionPart() {
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
if (!enabled) { playing?.cancel(); playing = null }
if (!enabled) cancelAll()
}
override fun start() {
@@ -64,7 +67,7 @@ class RumbleComponent : SessionPart() {
try {
val bytes = ByteArray(8)
DataInputStream(client.inputStream).readFully(bytes)
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4), u16(bytes, 6))
} catch (e: Exception) {
// A partial packet or a closed peer: nothing to play.
} finally {
@@ -79,11 +82,9 @@ class RumbleComponent : SessionPart() {
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
preferences = null
enabled = false
playing?.cancel()
playing = null
cancelAll()
phone = null
controller = null
controllerId = PadBridge.NO_CONTROLLER
controllers.clear()
val s = server
server = null
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
@@ -91,33 +92,48 @@ class RumbleComponent : SessionPart() {
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) {
if (!enabled) return
val target = target()
if (target !== playing) {
playing?.cancel()
playing = target
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
val target = target(slot)
val previous = playing[slot]
if (target !== previous) {
previous?.cancel()
sent.remove(slot)
if (target == null) playing.remove(slot) else playing[slot] = target
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
// Games re-send their effect every frame; restarting the motor that often floods the haptics
// driver. ponytail: a change inside MIN_GAP_MS is dropped, the game's next packet brings it.
val now = SystemClock.uptimeMillis()
val last = sent[slot]
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
val duration = ms.toLong().coerceIn(1L, 5000L)
try {
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
target.play(strong, weak, duration)
sent[slot] = Sent(strong, weak, now, now + duration)
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
}
/** The active controller's motors when it has any, else the phone's. */
private fun target(): Motors? {
val id = PadBridge.activeControllerId()
if (id != controllerId) {
controllerId = id
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
}
return controller ?: phone
/** The motors of the controller playing in this slot when it has any, else the phone's. */
private fun target(slot: Int): Motors? {
val id = PadBridge.controllerForSlot(slot)
if (id == PadBridge.NO_CONTROLLER) return phone
if (!controllers.containsKey(id)) controllers[id] = try { controllerMotors(id) } catch (e: Exception) { null }
return controllers[id] ?: phone
}
private fun cancelAll() {
playing.values.toSet().forEach { it.cancel() }
playing.clear()
sent.clear()
}
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
internal interface Motors {
val name: String
@@ -158,6 +174,8 @@ class RumbleComponent : SessionPart() {
private const val TAG = "SessionService"
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
const val NAME = "droiddeck-rumble"
/** The shortest time between two effects started on one slot's motors. */
const val MIN_GAP_MS = 50L
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
@@ -52,6 +52,26 @@ object SessionDisplay {
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv())
/** Unfolded controls: the most of the window the game may take, so a window near 16:9 itself
* still leaves the Deck controls room. */
const val UNFOLDED_MAX_GAME_SHARE = 0.8f
/** A foldable: Android 11's hinge angle sensor, which foldables report and slates do not. Unfolded
* controls are offered only there - untested on tablets, whose 16:10 landscape leaves them
* little room under a 16:9 game. */
fun foldable(context: Context): Boolean =
context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")
/** A panel at least this wide on its shortest side (dp) is a foldable's unfolded inner screen. */
const val UNFOLDED_MIN_DP = 600
/** Unfolded controls: the game's height in a window of this size - 16:9 across the full width,
* held either way, with even sides - and the Deck controls take the rest below it, laid out to
* whatever that leaves (DeckControlsPanel's GamepadLayout). */
fun unfoldedGameHeight(width: Int, height: Int): Int =
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
@@ -20,6 +20,19 @@ object SessionFiles {
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
// Staged only with a complete desktop; removal matches these against the APK as well.
internal val DESKTOP_FILES = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
// Games and emulators from the menu, full screen in a gamescope of their own.
"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",
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
"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",
)
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
*
@@ -30,6 +43,8 @@ object SessionFiles {
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
WinComponents.publish(context)
val files = arrayOf(
@@ -42,15 +57,19 @@ object SessionFiles {
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
"libblaudit.so" to "usr/local/lib/libblaudit.so",
"libssbs.so" to "usr/local/lib/libssbs.so",
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
"usr/local/bin/droiddeck-desktop-clipboard" to "usr/local/bin/droiddeck-desktop-clipboard",
"usr/local/bin/droiddeck-gpu-window" to "usr/local/bin/droiddeck-gpu-window",
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
@@ -75,6 +94,10 @@ object SessionFiles {
// The user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
// The desktop's Run entries and its exec fallback: Windows programs through Steam's Proton,
// x86 Linux programs through FEX.
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -91,17 +114,6 @@ object SessionFiles {
"usr/bin/steamos-polkit-helpers/jupiter-biosupdate" to "usr/bin/steamos-polkit-helpers/jupiter-biosupdate",
"usr/bin/steamos-polkit-helpers/jupiter-dock-updater" to "usr/bin/steamos-polkit-helpers/jupiter-dock-updater",
)
// The desktop's launchers, only where the desktop package is installed:
// staging them into a runtime without it would make the desktop look present when it is not.
val desktop = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
// Games and emulators from the menu, full screen in a gamescope of their own.
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
"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",
)
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
// apk carries it - a build without the asset leaves the runtime's copy alone.
@@ -121,8 +133,8 @@ object SessionFiles {
.map { "usr/local/lib/droiddeck-msitools/$it" } +
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
@@ -131,7 +143,7 @@ object SessionFiles {
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (DesktopCatalog.desktopInstalled(root)) files + desktop else files) + optional
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
for ((asset, relative) in all) {
val target = File(root, relative)
val staged = File(target.parentFile, target.name + ".staged")
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.TextureFiltering
@@ -18,10 +19,15 @@ object SessionPrefs {
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val CONTROLLER_STEAM = "steamcontroller"
const val CONTROLLER_DS4 = "dualshock4"
const val CONTROLLER_DUALSENSE = "dualsense"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
const val OSC_NEVER = "never"
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
const val OSC_STEAM_TOUCH = "steam-touch"
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
@@ -33,6 +39,22 @@ object SessionPrefs {
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
val orientationOptions = listOf(
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
)
fun orientation(context: Context): Int =
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
.takeIf { value -> orientationOptions.any { it.first == value } }
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fun setOrientation(context: Context, orientation: Int) {
require(orientationOptions.any { it.first == orientation })
prefs(context).edit().putInt("orientation", orientation).apply()
}
/** Whether the launcher hides Android's status and navigation bars. */
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
@@ -112,9 +134,9 @@ object SessionPrefs {
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
/** A Steam session with Deck mode's performance overlay (mangoapp). */
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
@@ -430,27 +452,21 @@ object SessionPrefs {
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On by default (settleDeckModeDefault). */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
/**
* Deck mode is the default: every install, new or from before, is moved to it once. An install
* from before 0.3.0 had off written down for it whether or not anyone chose it, so the move
* can't tell a choice from that default and moves everyone; turning it off afterwards sticks.
* Run once at process start, before anything reads or writes these prefs.
*/
fun settleDeckModeDefault(context: Context) {
val p = prefs(context)
if (p.getBoolean("deckModeMoved", false)) return
p.edit().putBoolean("steamDeckMode", true).putBoolean("deckModeMoved", true).apply()
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
fun padType(id: String): Int = when (id) {
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
}
/**
@@ -584,24 +600,6 @@ object SessionPrefs {
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/**
* The Steam client branch used for the first download and forced on the command line:
* "steamdeck_publicbeta" by default, or "publicbeta" when chosen with Deck mode off.
* Deck mode always takes the Deck branch, whatever was chosen:
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
*/
fun steamChannel(context: Context): String =
if (steamDeckMode(context)) "steamdeck_publicbeta"
else prefs(context).getString("steamChannel", null) ?: "steamdeck_publicbeta"
fun setSteamChannel(context: Context, id: String) {
prefs(context).edit().putString("steamChannel", id).apply()
}
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
@@ -610,8 +608,9 @@ object SessionPrefs {
}
/**
* The folders of the user's own Windows games (one game per subfolder), any number of them
* from anywhere on the device. The single folder an earlier build kept is carried in.
* The Games folders earlier builds let the user add (one game per subfolder); read only by
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
* even earlier build kept is carried in.
*/
fun addedGamesDirs(context: Context): List<String> {
val p = prefs(context)
@@ -625,6 +624,80 @@ object SessionPrefs {
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
}
/**
* The Games folders earlier builds had (host path, guest path), still bound into the session so
* their games keep their paths; written once by GamesFolderMigration.
*/
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
}
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
}
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
fun setGamesFoldersMigrated(context: Context) {
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
}
/**
* Game folders (by path) the user removed from the added games: a scan skips them, so they
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
*/
fun removedAddedGames(context: Context): List<String> =
(prefs(context).getString("removedAddedGames", "") ?: "").split('\n').filter { it.isNotEmpty() }
fun setAddedGameRemoved(context: Context, folderPath: String, removed: Boolean) {
synchronized(addedGameLock) {
val now = removedAddedGames(context).filter { it != folderPath } + listOfNotNull(folderPath.takeIf { removed })
prefs(context).edit().putString("removedAddedGames", now.joinToString("\n")).apply()
}
}
/** An added game's name as the user set it (in the app, or in Steam's Properties); "" = automatic. */
fun addedGameName(context: Context, folderPath: String): String = prefs(context).getString("addedName:$folderPath", "") ?: ""
fun setAddedGameName(context: Context, folderPath: String, name: String) {
prefs(context).edit().putString("addedName:$folderPath", name.trim()).apply()
}
/** An added game's Start in, as the session sees it; "" = the exe's own folder. */
fun addedGameStartIn(context: Context, folderPath: String): String = prefs(context).getString("addedStartIn:$folderPath", "") ?: ""
fun setAddedGameStartIn(context: Context, folderPath: String, guestDir: String) {
prefs(context).edit().putString("addedStartIn:$folderPath", guestDir.trim()).apply()
}
/** An added game's launch options, as Steam takes them ("-dx11", "VAR=1 %command%"). */
fun addedGameLaunch(context: Context, folderPath: String): String = prefs(context).getString("addedLaunch:$folderPath", "") ?: ""
fun setAddedGameLaunch(context: Context, folderPath: String, options: String) {
prefs(context).edit().putString("addedLaunch:$folderPath", options.trim()).apply()
}
/**
* The last value of [field] (AppName, StartDir, LaunchOptions) taken over from Steam's own
* Properties for this game, so the same Steam edit is not taken again over a later app edit.
*/
fun addedGameAdopted(context: Context, folderPath: String, field: String): String? =
prefs(context).getString("addedAdopted:$field:$folderPath", null)
fun setAddedGameAdopted(context: Context, folderPath: String, field: String, value: String) {
prefs(context).edit().putString("addedAdopted:$field:$folderPath", value).apply()
}
/** Where an added game's art for [slot] comes from when the user chose it ("folder", "steam", "sgdb", "file"); "" = automatic. */
fun addedGameArtSource(context: Context, folderPath: String, slot: String): String =
prefs(context).getString("addedArt:$slot:$folderPath", "") ?: ""
fun setAddedGameArtSource(context: Context, folderPath: String, slot: String, source: String) {
prefs(context).edit().putString("addedArt:$slot:$folderPath", source).apply()
}
/** Whether added games without art of their own get Steam's store art fetched for them. */
fun addedGamesArt(context: Context): Boolean = prefs(context).getBoolean("addedGamesArt", true)
@@ -650,6 +723,10 @@ object SessionPrefs {
unchanged
}
/** The shortcut appid stored for the folder, or null before its first scan. */
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
val p = prefs(context)
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
@@ -823,6 +900,26 @@ object SessionPrefs {
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
}
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
fun setUnfoldedControls(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
}
const val SURFACE_STICK = "stick"
const val SURFACE_TRACKPAD = "trackpad"
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
fun gamepadSurface(context: Context, right: Boolean): String =
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
@@ -29,6 +29,7 @@ import com.droiddeck.launcher.audio.PulseAudioComponent
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.DeviceReport
import com.droiddeck.launcher.core.HostEnvironment
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.SessionLogCapture
import com.droiddeck.launcher.core.NetworkReport
import com.droiddeck.launcher.core.SessionPart
@@ -36,9 +37,12 @@ import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.ProotFastPath
import com.droiddeck.launcher.runtime.UserApps
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.text.SimpleDateFormat
@@ -205,6 +209,10 @@ class SessionService : Service() {
return START_NOT_STICKY
}
}
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
stopSelf(startId)
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
if (SessionState.running) return START_NOT_STICKY
if (SessionState.stopRequested) {
@@ -402,6 +410,8 @@ class SessionService : Service() {
guest.add(it)
Log.i(TAG, "steam: handing the client $it")
}
// A game whose download finishes in this session gets its Windows components readied in it.
if (SessionState.mode == MODE_STEAM) com.droiddeck.launcher.frontend.SessionInstalls.watch(this)
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
@@ -615,8 +625,7 @@ class SessionService : Service() {
guest.add("ZINK_DEBUG=compact")
}
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
// thread and no GL error checks.
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
@@ -629,9 +638,7 @@ class SessionService : Service() {
// every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap
// 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too.
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
}
@@ -675,7 +682,9 @@ class SessionService : Service() {
// changes what games do and leaves the client alone. The microphone is its own opt-in on
// top, and the helper only opens an input stream when asked - so a user who wants game
// sound but no recording gets exactly that, and Android's recording indicator stays off.
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
// The desktop starts Windows programs through the same Proton wrappers (droiddeck-proton-run).
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) &&
SessionPrefs.directAudio(this)
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
// Both paths sit under the app's files directory, which the session binds at its own path,
@@ -733,10 +742,18 @@ class SessionService : Service() {
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
// Decided only once its sysfs is in place: without it the client would find no Deck, and
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
// Set up whatever the pad is to be, so the drawer can switch it between a Deck, a Steam
// Controller and an Xbox 360 pad while the session runs (FakeInputWriter.setPadType).
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
SessionState.steamUi != "desktop" &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
// A paired Triton is exposed as a second native Steam Controller HID, with its original
// report IDs and output path, beside whatever the handheld's own pad is.
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
logControllersAtStart()
@@ -746,6 +763,15 @@ class SessionService : Service() {
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) {
guest.add("FAKE_EVDEV_DECK=1")
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
// What the pad starts as; the drawer's changes reach libfakeinput through the ring.
val padType = SessionPrefs.steamController(this)
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
}
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
}
} else if (SessionState.mode == MODE_STEAM) {
@@ -1202,6 +1228,13 @@ class SessionService : Service() {
}
return
}
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
val request = File(dir, path)
val text = runCatching { request.readText() }.getOrNull() ?: return
request.delete()
importForDesktop(dir, text)
return
}
if (path != "steam-launch") return
val file = File(dir, path)
val text = try { file.readText() } catch (e: Exception) { return }
@@ -1231,6 +1264,48 @@ class SessionService : Service() {
launchWatcher = watcher
}
private val desktopImports = java.util.concurrent.ConcurrentHashMap<String, Any>()
/**
* What the desktop cannot run where it lies (droiddeck-open), added as Add an app adds it, menu entry
* included: an AppImage of sharun's, whose hard links Android denies apps and only the app's own proot
* (link2symlink) makes, or a script or program whose folder of Linux programs is on noexec shared
* storage, copied in. The answer goes beside the request, "ok <folder>" or "error <why>".
*/
private fun importForDesktop(dir: File, text: String) {
val fields = text.lineSequence().mapNotNull { line ->
line.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].trim() }
}.toMap()
val key = fields["id"]?.takeIf { it.matches(Regex("[0-9a-f]{16}")) } ?: return
val guestPath = fields["path"]?.takeIf { it.startsWith("/") && !it.contains("/../") } ?: return
val folder = fields["kind"] == "folder"
val binds = LinuxRuntime.lastBinds(this).map { BwrapSpawner.Bind.parse(it) }
val file = File(BwrapSpawner.hostPath(binds, LinuxRuntime.rootDir(this).path, guestPath))
val context = applicationContext
// A request for what is already being added waits for that, then finds it.
val lock = desktopImports.computeIfAbsent(key) { Any() }
Thread({
fun imported() = UserApps.desktopImport(context, key, guestPath)?.let { "ok $it" }
val request = UserApps.Request(
if (folder) UserApps.Source.Script(file) else UserApps.Source.AppImage(file),
if (folder) file.parentFile?.name else null, null, mapOf(UserApps.DESKTOP_IMPORT to "$key\n$guestPath"),
)
val answer = synchronized(lock) {
try {
imported() ?: UserApps.add(context, request) { _, _ -> }?.let { "error $it" }
?: imported() ?: "error " + context.getString(R.string.user_apps_failed)
} catch (e: Exception) {
Log.e(TAG, "Desktop import of $guestPath", e)
"error " + (e.message ?: context.getString(R.string.user_apps_failed))
}
}
Log.i(TAG, "Desktop import of $guestPath: $answer")
val result = File(dir, "desktop-import-$key.result")
val partial = File(dir, result.name + ".tmp")
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
}, "desktop-import").start()
}
@Volatile private var syncWatcher: android.os.FileObserver? = null
private fun watchSyncWanted(root: File) {
@@ -1501,6 +1576,7 @@ class SessionService : Service() {
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
com.droiddeck.launcher.input.SteamTouchDevice.release()
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
val finishAfterTeardown: () -> Unit = {
if (prootPid > 1 || auxiliary.isNotEmpty()) {
@@ -1563,8 +1639,10 @@ class SessionService : Service() {
}
try {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
val allowed = DeviceSupport.lowLatencyWifiSafe()
if (!allowed) Log.i(TAG, "wifi lock skipped: its low-latency mode resets this device")
@Suppress("DEPRECATION") // deprecated from API 29, still honoured; targetSdk is 28
if (wifiLock?.isHeld != true) {
if (allowed && wifiLock?.isHeld != true) {
wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "DroidDeck:session-wifi")
?.apply {
setReferenceCounted(false)
@@ -1704,7 +1782,7 @@ class SessionService : Service() {
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
}
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
}
companion object {
@@ -1761,6 +1839,7 @@ class SessionService : Service() {
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
private const val NO_STEAM_TOUCH_SWITCH = "Download/droiddeck-no-steam-touch"
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
private const val FIRST_VIRTUAL_PAD = 16
@@ -44,6 +44,54 @@ object SteamDeckPad {
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
private const val TRITON_MINOR = 18
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
private const val TRITON_SERIAL = "DROIDDECKSC01"
// The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the wired
// controller (28de:1302) on a USB device of its own, since it is built in.
private const val TRITON_USB = "$GUEST_DEVICES/usb3"
private const val TRITON_INTERFACE = "$TRITON_USB/3-1:1.0"
private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004"
/** Must match libfakeinput (DS4_HIDRAW_MINOR, DUALSENSE_HIDRAW_MINOR). */
private const val SONY_DS4_MINOR = 20
private const val SONY_DUALSENSE_MINOR = 21
/** Must match libfakeinput's kDs4ReportDescriptor and kDualSenseReportDescriptor. */
private val DS4_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02,
0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xb1, 0x02, 0x85, 0x12, 0x09, 0x25, 0x95, 0x0f, 0xb1, 0x02,
0x85, 0xa3, 0x09, 0x26, 0x95, 0x30, 0xb1, 0x02, 0x85, 0x81, 0x09, 0x27, 0x95, 0x3f, 0xb1, 0x02,
0xc0,
).map { it.toByte() }.toByteArray()
private val DUALSENSE_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x02, 0x09, 0x21, 0x95, 0x2f, 0x91, 0x02,
0x85, 0x05, 0x09, 0x22, 0x95, 0x28, 0xb1, 0x02, 0x85, 0x09, 0x09, 0x23, 0x95, 0x13, 0xb1, 0x02,
0x85, 0x20, 0x09, 0x24, 0x95, 0x3f, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Must match libfakeinput (TRITON_PAD_HIDRAW_*): its own node, so a paired controller keeps 18. */
private const val TRITON_PAD_MINOR = 19
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
private const val TOUCH_MINOR = 17
private const val TOUCH_NODE = "hidraw$TOUCH_MINOR"
private const val TOUCH_USB = "$GUEST_DEVICES/usb2"
private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0"
private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002"
private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE"
private const val TOUCH_SERIAL = "DROIDDECK0001"
/** libfakeinput's kTouchReportDescriptor: Generic Desktop / Game Pad, 40-byte input report. */
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
private val REPORT_DESCRIPTOR = intArrayOf(
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
@@ -51,11 +99,65 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
0xc0,
).map { it.toByte() }.toByteArray()
/** The wired controller's: its 0x42 state report in place of the Bluetooth 0x45. */
private val TRITON_WIRED_REPORT_DESCRIPTOR = TRITON_REPORT_DESCRIPTOR.copyOf().also { it[15] = 0x42 }
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File): List<String> {
/** The pad's own devices by minor (the Deck, its Steam Controller, the PlayStation pads): guest paths. */
private val padNodes = mutableMapOf<Int, String>()
@Volatile private var hidrawClassDir: File? = null
/**
* Lists only the device the pad is now ([padType], FakeInputWriter.PAD_TYPE_*) in
* /sys/class/hidraw, none for an Xbox 360 pad. Device scans then find what is plugged in: SDL's
* (the PlayStation pads' driver) keeps a device it found and could not open, and never tries it
* again, while one that disappears from the listing is let go. Before the shim is told.
*/
@Synchronized
fun showPad(padType: Int) {
val dir = hidrawClassDir ?: return
val shown = when (padType) {
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK -> MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR
else -> -1
}
for ((minor, target) in padNodes) {
val link = File(dir, "hidraw$minor")
try {
if (minor == shown) {
if (!link.exists()) { link.delete(); Os.symlink(target, link.path) }
} else {
link.delete()
}
} catch (e: Exception) {
Log.w(TAG, "pad: could not list /dev/hidraw$minor: $e")
}
}
}
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false,
padType: Int = com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
@@ -95,7 +197,8 @@ object SteamDeckPad {
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
padNodes.clear()
padNodes[MINOR] = HIDRAW
// Only the names matter: libudev reads a listing for the subsystems to descend into and
// then opens /sys/class/hidraw itself, which is the bind above.
@@ -107,6 +210,116 @@ object SteamDeckPad {
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
if (touch) {
write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n")
write(TOUCH_USB, "idVendor", "0000\n")
write(TOUCH_USB, "idProduct", "11fb\n")
write(TOUCH_USB, "bcdDevice", "0000\n")
write(TOUCH_USB, "manufacturer", "Valve Software\n")
write(TOUCH_USB, "product", "Mobile Touch Control\n")
write(TOUCH_USB, "serial", "$TOUCH_SERIAL\n")
link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb")
write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n")
write(TOUCH_INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb")
write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" +
"HID_UNIQ=$TOUCH_SERIAL\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid")
write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n")
write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n")
link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID)
link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW)
link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW)
File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
}
// A Steam Controller's HID device: the handheld's own pad as the wired one, or the paired
// one on Bluetooth. Both are always described; libfakeinput offers whichever is in use.
fun describeTriton(wired: Boolean) {
val hid = if (wired) TRITON_WIRED_HID else TRITON_HID
val product = if (wired) "1302" else "1303"
val bus = if (wired) "0003" else "0005"
val minor = if (wired) TRITON_PAD_MINOR else TRITON_MINOR
val node = "hidraw$minor"
if (wired) {
write(TRITON_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1302/100\nTYPE=0/0/0\nBUSNUM=003\nDEVNUM=002\n")
write(TRITON_USB, "idVendor", "28de\n")
write(TRITON_USB, "idProduct", "1302\n")
write(TRITON_USB, "bcdDevice", "0100\n")
write(TRITON_USB, "manufacturer", "Valve Software\n")
write(TRITON_USB, "product", "Steam Controller\n")
write(TRITON_USB, "serial", "$TRITON_SERIAL\n")
link(File(dir(TRITON_USB), "subsystem"), "/sys/bus/usb")
write(TRITON_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1302/100\nINTERFACE=3/0/0\n")
write(TRITON_INTERFACE, "bInterfaceNumber", "00\n")
write(TRITON_INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(TRITON_INTERFACE), "subsystem"), "/sys/bus/usb")
}
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=$bus:000028DE:0000$product\n" +
"HID_NAME=Valve Software Steam Controller\n" +
"HID_PHYS=${if (wired) "usb-droiddeck-3/input0" else "bluetooth-droiddeck-1"}\n" +
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b${bus}g0001v000028DEp0000$product\n")
File(dir(hid), "report_descriptor").writeBytes(if (wired) TRITON_WIRED_REPORT_DESCRIPTOR else TRITON_REPORT_DESCRIPTOR)
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
val hidraw = "$hid/hidraw/$node"
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
write(hidraw, "dev", "$MAJOR:$minor\n")
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
link(File(dir(hidraw), "device"), hid)
if (wired) padNodes[minor] = hidraw else link(File(hidrawClass, node), hidraw)
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)")
}
// The handheld's own pad as a PlayStation controller, each a USB device of its own with
// the report descriptor libfakeinput serves (kDs4/kDualSenseReportDescriptor).
fun describeSony(dualSense: Boolean) {
val bus = if (dualSense) 5 else 4
val product = if (dualSense) "0ce6" else "09cc"
val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR
val node = "hidraw$minor"
val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller"
val usb = "$GUEST_DEVICES/usb$bus"
val iface = "$usb/$bus-1:1.3"
val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}"
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=54c/$product/100\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
write(usb, "idVendor", "054c\n")
write(usb, "idProduct", "$product\n")
write(usb, "bcdDevice", "0100\n")
write(usb, "manufacturer", "Sony Interactive Entertainment\n")
write(usb, "product", "$name\n")
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=54c/$product/100\nINTERFACE=3/0/0\n")
write(iface, "bInterfaceNumber", "03\n")
write(iface, "bInterfaceClass", "03\n")
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" +
"HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" +
"HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n")
File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR)
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
val hidraw = "$hid/hidraw/$node"
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
write(hidraw, "dev", "$MAJOR:$minor\n")
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
link(File(dir(hidraw), "device"), hid)
padNodes[minor] = hidraw
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product")
}
describeSony(dualSense = false)
describeSony(dualSense = true)
describeTriton(wired = true)
if (triton) describeTriton(wired = false)
hidrawClassDir = hidrawClass
showPad(padType)
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
@@ -38,7 +38,7 @@ import java.util.zip.ZipInputStream
*/
object WinComponents {
private const val TAG = "WinComponents"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/components.json"
private const val SELECTION = "wincomponents.json"
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
private const val STORE = "opt/droiddeck/wincomponents"
@@ -59,19 +59,39 @@ object WinComponents {
*/
private val RUNS_OWN_SETUP = setOf(
"K-Lite", "ffdshow", "lavfilters702", "lavfilters741", "quicktime72", "dirac", "webview2", "aairruntime",
"gfw", "ie8_kb2936068", "art2kmin", "art2k7min", "vcredist6sp6", "VulkanRT", "jet40", "mdac28", "oalinst",
"gfw", "ie8_kb2936068", "art2k7min", "vcredist6sp6", "VulkanRT", "jet40", "mdac28", "oalinst",
"dotnet20", "dotnet20sp1", "dotnet35", "dotnet35sp1",
)
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
/** Where a downloaded package or installer waits for the installer, inside the runtime so it can read it. */
private const val MSI_CACHE = "$STORE/.packages"
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
private val PROTON_PROVIDES = setOf("gecko", "mono")
/**
* Catalog entries every Proton here already ships, which an install would only shadow: Wine
* Gecko and Mono, the Chinese/Japanese/Korean fonts (Malgun, MS Gothic, YaHei, SimSun are in
* share/fonts of Proton Experimental, GE-Proton and proton-cachyos alike), Wine's own
* PowerShell wrapper and the Vulkan loader (vulkan-1.dll, 32- and 64-bit, in all three).
*/
private val PROTON_PROVIDES = setOf("gecko", "mono", "cjkfonts", "powershell", "VulkanRT")
/** The keys the catalog nests under "environment" on a few .NET entries. */
private val NESTED_KEYS = setOf("url", "file_name", "file_checksum", "file_size")
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
private val BACKGROUND = ThreadLocal<Boolean>()
/**
* Runs [block] as background work: the runtime helpers an install starts on this thread run
* at the lowest CPU and I/O priority (nice 19, ionice idle where the runtime has it).
*/
fun <T> background(block: () -> T): T {
BACKGROUND.set(true)
try { return block() } finally { BACKGROUND.remove() }
}
private fun guest(argv: List<String>): List<String> = if (BACKGROUND.get() != true) argv else listOf(
"/bin/sh", "-c", "if command -v ionice >/dev/null 2>&1; then exec nice -n 19 ionice -c3 \"\$@\"; else exec nice -n 19 \"\$@\"; fi", "sh",
) + argv
class Step(val action: String, val json: JSONObject) {
fun str(key: String): String = json.optString(key, "").ifEmpty {
if (key in NESTED_KEYS) json.optJSONObject("environment")?.optString(key, "").orEmpty() else ""
@@ -81,6 +101,14 @@ object WinComponents {
class Component(
val name: String, val description: String, val provider: String, val status: String,
val dependencies: List<String>, val steps: List<Step>,
/**
* A recording of what the component's own installer leaves in a prefix (winlator-contents'
* component-snapshots release, made by tools/snapshot/record.py): the files it places and
* the registry values it writes. With one, the installer never runs: the files come from
* the archive beside the recording or out of the vendor's installer, the registry from the
* recording. Empty for a component without one.
*/
val snapshot: String = "",
)
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
@@ -105,10 +133,39 @@ object WinComponents {
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
.any { it.endsWith(".msi", ignoreCase = true) || it.endsWith(".exe", ignoreCase = true) })
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name.startsWith("mono-")
/**
* Catalog entries nothing here can use: the IE8 security update patches a browser no prefix
* has; the OpenAL setup program is a twin of oalinst_dll, which copies the same DLLs; and
* WebView2 can only be installed by running Microsoft's installer in the prefix (its files
* sit in a container nothing opens and may not be hosted), which no runtime here does.
*/
private val POINTLESS = setOf("ie8_kb2936068", "oalinst", "webview2")
fun fetch(): List<Component>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name in POINTLESS || name.startsWith("mono-")
fun hasSnapshot(c: Component): Boolean = c.snapshot.startsWith("https://github.com/") && c.snapshot.endsWith(".snapshot.json")
/** The steps a recording makes redundant: everything that places files or edits the registry. */
private val SNAPSHOT_COVERS = INSTALLER_STEPS + setOf("download_archive", "archive_extract", "copy_dll", "copy_file") + OFFLINE_STEPS
fun fetch(): List<Component>? = Downloader.downloadString(CATALOG_URL)?.let { parse(it) }
private const val CATALOG_CACHE = "wincomponents-catalog.json"
/** The catalog from the network, kept for later; the copy kept last time when offline. */
fun fetch(context: Context): List<Component>? {
val cache = File(context.filesDir, CATALOG_CACHE)
Downloader.downloadString(CATALOG_URL)?.let { body ->
parse(body)?.let { runCatching { write(cache, body) }; return it }
}
return runCatching { parse(cache.readText()) }.getOrNull()
}
/** The copy of the catalog kept by [fetch], without asking the network; null before the first fetch. */
fun cached(context: Context): List<Component>? =
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
private fun parse(body: String): List<Component>? {
return try {
val arr = JSONObject(body).getJSONArray("components")
(0 until arr.length()).mapNotNull { i ->
@@ -121,6 +178,7 @@ object WinComponents {
name, o.optString("description"), o.optString("provider"), o.optString("status"),
(0 until deps.length()).map { deps.getString(it) }.filter { validId(it) },
(0 until steps.length()).map { steps.getJSONObject(it).let { s -> Step(s.optString("action"), s) } },
o.optString("snapshot"),
)
}
} catch (e: Exception) {
@@ -128,10 +186,25 @@ object WinComponents {
}
}
/**
* The catalog entry that installs [name] here: the detector names installers
* (vcredist2022, oalinst), and their DLL-copy twin (vcredist2022_dll, oalinst_dll) is the one
* that installs without running anything; [name] itself when there is no such twin.
*/
fun installable(name: String, all: Map<String, Component>): String {
val twin = all[if (name == "oalinst") "oalinst_dll" else "${name}_dll"]
return if (twin != null && support(twin, all) == Support.READY) twin.name else name
}
/** Whether [c] can be installed here, its bundled components included. */
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
if (c.status != "ready" || depth > 8 || protonProvides(c.name)) return Support.UNSUPPORTED
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
// A recording of the installer's result stands in for running it, whatever the steps say.
if (hasSnapshot(c)) {
return c.dependencies.map { all[it]?.let { d -> support(d, all, depth + 1) } ?: Support.UNSUPPORTED }
.maxOrNull() ?: Support.READY
}
fun offline(step: Step) = step.action == "delete_dlls" || step.action in OFFLINE_STEPS || step.action in FILE_STEPS && fileStepOk(step)
val own = when {
installers.isNotEmpty() && c.name !in RUNS_OWN_SETUP && installers.all { isPackage(it) } &&
@@ -212,7 +285,14 @@ object WinComponents {
val cabs = HashMap<String, File>()
val temp = File(root, "$MSI_CACHE/.t-${c.name}").apply { FileUtils.delete(this) }
try {
for (step in c.steps) when (step.action) {
if (hasSnapshot(c)) {
val (result, problem) = installSnapshot(context, c, staging, work, onProgress)
if (result == null) return problem
msi = result
}
for (step in c.steps) when {
hasSnapshot(c) && step.action in SNAPSHOT_COVERS -> Unit
else -> when (step.action) {
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
// same folder, the first one names the component.
"install_msi", "install_exe" -> {
@@ -337,6 +417,7 @@ object WinComponents {
}
"register_dll" -> Unit
else -> return "unsupported step ${step.action}"
}
}
} finally {
runtimeDirs.forEach { FileUtils.delete(it) }
@@ -355,7 +436,7 @@ object WinComponents {
.put("id", c.name).put("version", version)
.put("overrides", JSONArray(overrides.distinct()))
.put("requires", JSONArray(c.dependencies))
msi?.let { meta.put("kind", "msi").put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
msi?.let { meta.put("kind", it.optString("kind", "msi")).put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
FileUtils.writeString(File(staging, "component.json"), meta.toString(1))
val target = File(root, "$STORE/${c.name}")
FileUtils.delete(target)
@@ -363,6 +444,94 @@ object WinComponents {
return null
}
/**
* Lays the component out from its recording: downloads the snapshot, then its files (the
* archive beside it when the recording carries them, else out of the vendor's installer by
* the paths the recording maps, small generated ones inline) into [staging]/drive_c, and the
* recorded registry values into registry.json. Returns a result like the MSI engine's, or
* null and why.
*/
private fun installSnapshot(context: Context, c: Component, staging: File, work: File, onProgress: (Progress) -> Unit): Pair<JSONObject?, String> {
val root = LinuxRuntime.rootDir(context)
val name = c.snapshot.substringAfterLast('/')
val json = File(work, "${c.name}.snapshot.json")
if (!Downloader.downloadFile(c.snapshot, json, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $name"
}
val snapshot = runCatching { JSONObject(json.readText()) }.getOrNull() ?: return null to "the recording could not be read"
if (snapshot.optInt("schema") != 1) return null to "the recording is of a newer kind than this app reads"
val files = snapshot.optJSONArray("files") ?: JSONArray()
val notes = ArrayList<String>()
snapshot.optJSONArray("notes")?.let { for (i in 0 until it.length()) notes += it.getString(i) }
val drive = File(staging, "drive_c")
var placed = 0
val archive = snapshot.optJSONObject("files_archive")
if (archive != null) {
val archiveName = archive.optString("name")
val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName
val file = File(work, "${c.name}.files.tar.xz")
if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $archiveName"
}
archive.optString("sha256").takeIf { it.length == 64 }?.let { sha ->
if (!digest(file, "SHA-256").equals(sha, ignoreCase = true)) return null to "checksum mismatch: $archiveName"
}
onProgress(Progress(c.name, "unpacking $archiveName", Phase.INSTALL, -1))
if (!extract(file, staging)) return null to "could not unpack $archiveName"
file.delete()
placed = drive.walkTopDown().count { it.isFile }
} else {
// The files come out of the installer the catalog names, by the paths the recording maps.
val step = c.steps.firstOrNull { it.action in INSTALLER_STEPS && it.str("url").startsWith("https://") }
?: return null to "the recording needs the installer, which the catalog does not name"
val url = step.str("url")
val installerName = url.substringBefore('?').substringAfterLast('/')
val cache = File(root, MSI_CACHE).apply { mkdirs() }
val installer = File(cache, "${c.name}-snapshot-src.bin")
var unpacked: File? = null
try {
if (!Downloader.downloadFile(url, installer, false) { f -> onProgress(Progress(c.name, installerName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $installerName"
}
unpacked = unpackInRuntime(context, c, installer, "snapshot", onProgress) ?: return null to "could not open $installerName"
var missing = 0
for (i in 0 until files.length()) {
val entry = files.getJSONObject(i)
val rel = entry.optString("path")
val target = ArchivePaths.inside(drive, rel) ?: continue
val source = entry.optString("source")
when {
source.isNotEmpty() -> ArchivePaths.inside(unpacked, source)?.takeIf { it.isFile }?.let { src ->
target.parentFile?.mkdirs(); src.copyTo(target, overwrite = true); placed++
} ?: run { missing++ }
entry.has("data") -> {
target.parentFile?.mkdirs()
target.writeBytes(android.util.Base64.decode(entry.getString("data"), android.util.Base64.DEFAULT)); placed++
}
else -> missing++
}
if (i % 20 == 0) onProgress(Progress(c.name, "placing files ($placed of ${files.length()})", Phase.INSTALL, (i + 1) * 100 / maxOf(1, files.length())))
}
if (missing > 0) notes += "$missing file(s) the installer generates could not be reproduced here"
} finally {
installer.delete()
unpacked?.let { FileUtils.delete(it) }
}
}
onProgress(Progress(c.name, "writing the registry values", Phase.INSTALL, 95))
val values = snapshot.optJSONArray("registry") ?: JSONArray()
FileUtils.writeString(File(staging, "registry.json"), JSONObject().put("version", 1).put("values", values).toString(1))
if (placed == 0 && values.length() == 0) return null to "the recording holds nothing to install"
val recorded = snapshot.optString("recorded").take(10)
val installerVersion = snapshot.optJSONObject("installer")?.optString("name").orEmpty()
val version = Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: recorded.ifEmpty { "recorded" }
return JSONObject()
.put("kind", "snapshot").put("product", c.description.ifEmpty { c.name })
.put("version", version)
.put("counts", JSONObject().put("files", placed).put("registry", values.length()))
.put("notes", JSONArray(notes + listOfNotNull(installerVersion.takeIf { it.isNotEmpty() }?.let { "recorded from $it on ${snapshot.optString("wine")}" }))) to ""
}
/** The regex for a catalog file pattern: * matches anything, case does not matter. */
private fun globRegex(pattern: String) =
Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
@@ -378,7 +547,7 @@ object WinComponents {
val inside = archive.relativeTo(root).path
var problem: String? = null
var ok = false
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}"),
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}")),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, _) -> onProgress(Progress(c.name, text, Phase.INSTALL, -1)) }
@@ -417,7 +586,7 @@ object WinComponents {
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
var result: JSONObject? = null
var problem: String? = null
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}")),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, pct) -> onProgress(Progress(c.name, text, Phase.INSTALL, pct)) }
@@ -494,36 +663,101 @@ object WinComponents {
fun uninstall(context: Context, id: String) {
require(validId(id))
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
save(context, selections(context).mapValues { (_, sel) -> Selection(sel.auto - id, sel.user - id) })
}
/**
* Why a component was turned on for a game by itself ([setAuto]): [kind] says whose list it came
* from (see AutoComponents), [args] what the reason line names. Kept beside the picks.
*/
class AutoReason(val kind: String, val args: List<String>)
/**
* Everything kept per game, by the appid its prefix is named after: [auto] - turned on by
* itself, with its reason; [user] - a switch the user pressed (true on, false off), which wins
* over [auto] either way. What the launch reads is [effective].
*/
class Selection(val auto: Map<String, AutoReason>, val user: Map<String, Boolean>) {
val effective: List<String>
get() = (auto.keys.filter { user[it] != false } + user.filterValues { it }.keys).distinct()
}
/** Each game's picks, by the appid its prefix is named after. */
@Synchronized
fun read(context: Context): Map<String, List<String>> = try {
fun selections(context: Context): Map<String, Selection> = try {
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
val games = json.optJSONObject("games") ?: JSONObject()
games.keys().asSequence().associateWith { app ->
val ids = games.getJSONArray(app)
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
val autos = json.optJSONObject("auto") ?: JSONObject()
val users = json.optJSONObject("user")
val keys = games.keys().asSequence().toSet() + autos.keys().asSequence().toSet() + (users?.keys()?.asSequence()?.toSet() ?: emptySet())
keys.associateWith { app ->
val auto = autos.optJSONObject(app)?.let { o ->
o.keys().asSequence().filter { validId(it) }.associateWith { id ->
val r = o.optJSONObject(id) ?: JSONObject()
val a = r.optJSONArray("args") ?: JSONArray()
AutoReason(r.optString("kind"), (0 until a.length()).map { a.optString(it) })
}
}.orEmpty()
// Before there were overrides, every pick was the user's own.
val user = if (users == null) games.optJSONArray(app)?.let { ids -> (0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }.associateWith { true } }.orEmpty()
else users.optJSONObject(app)?.let { o -> o.keys().asSequence().filter { validId(it) }.associateWith { o.optBoolean(it) } }.orEmpty()
Selection(auto, user)
}
} catch (_: java.io.FileNotFoundException) {
emptyMap()
}
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
/** Each game's picks as the launch takes them, by the appid its prefix is named after. */
fun read(context: Context): Map<String, List<String>> =
selections(context).mapValues { it.value.effective }.filterValues { it.isNotEmpty() }
fun picks(context: Context, appKey: String): List<String> = selections(context)[appKey]?.effective.orEmpty()
fun selection(context: Context, appKey: String): Selection = selections(context)[appKey] ?: Selection(emptyMap(), emptyMap())
/** The user's own switch for [id] in [appKey]: on or off, over whatever was picked by itself. */
@Synchronized
fun setPicks(context: Context, appKey: String, ids: List<String>) {
require(ids.all { validId(it) })
val all = read(context).toMutableMap()
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
fun setUser(context: Context, appKey: String, id: String, on: Boolean) {
require(validId(id))
val all = selections(context).toMutableMap()
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
all[appKey] = Selection(now.auto, now.user + (id to on))
save(context, all)
}
private fun save(context: Context, games: Map<String, List<String>>) {
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
}).toString()
/** The components picked by themselves for [appKey], replacing the earlier ones; the user's switches stay. */
@Synchronized
fun setAuto(context: Context, appKey: String, auto: Map<String, AutoReason>) {
require(auto.keys.all { validId(it) })
val all = selections(context).toMutableMap()
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
all[appKey] = Selection(auto, now.user)
save(context, all)
}
/** The user's picks for [appKey] set outright (each listed on, every other user switch dropped). */
@Synchronized
fun setPicks(context: Context, appKey: String, ids: List<String>) {
require(ids.all { validId(it) })
val all = selections(context).toMutableMap()
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
all[appKey] = Selection(now.auto, ids.distinct().associateWith { true } + now.auto.keys.filter { it !in ids }.associateWith { false })
save(context, all)
}
private fun save(context: Context, games: Map<String, Selection>) {
val kept = games.filterValues { it.auto.isNotEmpty() || it.user.isNotEmpty() }
// Version 1 still: droiddeck-wincomponents reads "games" and ignores the rest.
val text = JSONObject().put("version", 1)
.put("games", JSONObject().apply { kept.forEach { (app, sel) -> sel.effective.takeIf { it.isNotEmpty() }?.let { put(app, JSONArray(it)) } } })
.put("auto", JSONObject().apply {
kept.forEach { (app, sel) ->
if (sel.auto.isNotEmpty()) put(app, JSONObject().apply {
sel.auto.forEach { (id, r) -> put(id, JSONObject().put("kind", r.kind).put("args", JSONArray(r.args))) }
})
}
})
.put("user", JSONObject().apply { kept.forEach { (app, sel) -> if (sel.user.isNotEmpty()) put(app, JSONObject(sel.user as Map<*, *>)) } })
.toString()
write(File(context.filesDir, SELECTION), text)
publish(context, text)
}
@@ -36,9 +36,9 @@ object SteamLiveShortcuts {
private const val TAG_NAME = "droiddeck-app"
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
fun sync(context: Context, games: List<AddedGames.Game>) {
val wanted = games.filter { it.source != Library.ADDED }
/** Adds every game in [games] that [include] takes (store games by default) and the client does not list yet. Blocking; worker thread. */
fun sync(context: Context, games: List<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) {
val wanted = games.filter(include)
if (wanted.isEmpty() || !clientRunning()) return
val list = JSONArray()
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
@@ -71,6 +71,47 @@ object SteamLiveShortcuts {
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
}
/**
* The running client's shortcut for [game] made to match it: name, target, Start in and
* launch options, on the same appid (an edit never adds a second shortcut). Blocking.
*/
fun update(context: Context, game: AddedGames.Game) {
if (!clientRunning()) return
val id = game.appId and 0xFFFFFFFFL
val js = """
(async () => {
const id = $id;
try { SteamClient.Apps.SetShortcutName(id, ${JSONObject.quote(game.name)}); } catch (e) {}
try { SteamClient.Apps.SetShortcutExe(id, ${JSONObject.quote("\"" + game.guestExe + "\"")}); } catch (e) {}
try { SteamClient.Apps.SetShortcutStartDir(id, ${JSONObject.quote("\"" + game.guestDir + "\"")}); } catch (e) {}
try { SteamClient.Apps.SetShortcutLaunchOptions(id, ${JSONObject.quote(game.launchOptions)}); } catch (e) {}
return 1;
})()
""".trimIndent()
if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: updated $id")
}
/**
* One piece of art for the running client's shortcut [appId]: [assetType] as the client numbers
* them (0 portrait capsule, 1 hero, 2 logo, 3 wide capsule), from [file]; null clears it. The
* icon goes by path ([iconPath]). Blocking.
*/
fun setArt(context: Context, appId: Long, assetType: Int?, file: java.io.File?, iconPath: String? = null) {
if (!clientRunning()) return
val id = appId and 0xFFFFFFFFL
val js = when {
iconPath != null -> "(async () => { try { SteamClient.Apps.SetShortcutIcon($id, ${JSONObject.quote(iconPath)}); } catch (e) {} return 1; })()"
assetType == null -> return
file == null -> "(async () => { try { await SteamClient.Apps.ClearCustomArtworkForApp($id, $assetType); } catch (e) {} return 1; })()"
else -> {
val data = runCatching { Base64.encodeToString(file.readBytes(), Base64.NO_WRAP) }.getOrNull() ?: return
val type = if (file.extension.equals("png", true)) "png" else "jpg"
"(async () => { try { await SteamClient.Apps.SetCustomArtworkForApp($id, ${JSONObject.quote(data)}, ${JSONObject.quote(type)}, $assetType); } catch (e) {} return 1; })()"
}
}
if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: art $assetType for $id")
}
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
@@ -52,6 +52,9 @@ object StoreInstalls {
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
onStep?.invoke(2, STEPS)
register(app)
// Its Windows components from the store's list (GOG's dependencies, Epic's prerequisite,
// Amazon's install steps), on a thread of their own.
AddedGames.single(app, folder.path)?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, Library.addedGame(app, it)) }
onStep?.invoke(STEPS, STEPS)
}
@@ -64,7 +67,7 @@ object StoreInstalls {
try {
val games = AddedGames.scan(app)
AddedGames.writeListing(app, games)
LibraryCache.save(app, Library.launchableGames(app))
LibraryCache.save(app, Library.launchableGames(app, added = games))
SteamLiveShortcuts.sync(app, games)
} catch (e: Exception) {
Log.w(TAG, "registration: ${e.message}")
@@ -25,13 +25,15 @@ public final class AmazonApiClient {
private static final String TAG = "AmazonApi";
private static final String ENTITLEMENTS_URL = "https://gaming.amazon.com/api/distribution/entitlements";
private static final String DISTRIBUTION_URL = "https://gaming.amazon.com/api/distribution/v2/public";
private static final String GAMING_USER_AGENT = "com.amazon.agslauncher.win/3.0.9202.1";
// As nile sends them: the session's User-Agent, and the launcher's own string under "UserAgent".
private static final String GAMING_USER_AGENT = "AGSLauncher/1.0.0";
private static final String LAUNCHER_USER_AGENT = "com.amazon.agslauncher.win/3.0.9202.1";
private static final String DOWNLOAD_USER_AGENT = "nile/0.1 Amazon";
private static final String KEY_ID = "d5dc8b8b-86c8-4fc4-ae93-18c0def5314d";
private AmazonApiClient() {}
/** The owned games, every page, one per productId. */
/** The owned games, every page, one per productId; null when Amazon refused or could not be reached. */
public static List<AmazonGame> getEntitlements(String accessToken, String deviceSerial) {
Map<String, AmazonGame> seen = new HashMap<>();
String nextToken = null;
@@ -40,8 +42,9 @@ public final class AmazonApiClient {
hardwareHash = AmazonPkce.sha256Upper(deviceSerial);
} catch (Exception e) {
Log.e(TAG, "hardware hash failed", e);
return new ArrayList<>();
return null;
}
boolean answered = false;
do {
try {
JSONObject body = new JSONObject();
@@ -56,19 +59,22 @@ public final class AmazonApiClient {
String resp = postGaming(ENTITLEMENTS_URL, "com.amazon.animusdistributionservice.entitlement.AnimusEntitlementsService.GetEntitlements", accessToken, body.toString());
if (resp == null) break;
JSONObject json = new JSONObject(resp);
answered = true;
JSONArray entitlements = json.optJSONArray("entitlements");
if (entitlements == null) break;
for (int i = 0; i < entitlements.length(); i++) {
if (entitlements != null) for (int i = 0; i < entitlements.length(); i++) {
AmazonGame game = parseEntitlement(entitlements.getJSONObject(i));
if (game != null && !game.productId.isEmpty()) seen.put(game.productId, game);
}
nextToken = json.optString("nextToken", null);
// A JSON null reads as "null" through optString; the last page carries none.
nextToken = json.isNull("nextToken") ? null : json.optString("nextToken", "");
if (nextToken != null && nextToken.isEmpty()) nextToken = null;
} catch (Exception e) {
Log.e(TAG, "entitlements page failed: " + e.getClass().getSimpleName());
break;
}
} while (nextToken != null);
if (!answered) return null;
Log.i(TAG, "entitlements: " + seen.size());
return new ArrayList<>(seen.values());
}
@@ -140,7 +146,9 @@ public final class AmazonApiClient {
JSONObject body = new JSONObject().put("adgProductIds", new JSONArray().put(productId)).put("Operation", "GetLiveVersionIds");
String resp = postGaming(DISTRIBUTION_URL, "com.amazon.animusdistributionservice.external.AnimusDistributionService.GetLiveVersionIds", accessToken, body.toString());
if (resp == null) return null;
JSONObject versions = new JSONObject(resp).optJSONObject("versionIds");
JSONObject json = new JSONObject(resp);
JSONObject versions = json.optJSONObject("adgProductIdToVersionIdMap");
if (versions == null) versions = json.optJSONObject("versionIds");
return versions == null ? null : versions.optString(productId, null);
} catch (Exception e) {
Log.e(TAG, "GetLiveVersionIds failed: " + e.getClass().getSimpleName());
@@ -165,13 +173,17 @@ public final class AmazonApiClient {
conn.setRequestProperty("X-Amz-Target", target);
conn.setRequestProperty("x-amzn-token", accessToken);
conn.setRequestProperty("User-Agent", GAMING_USER_AGENT);
conn.setRequestProperty("UserAgent", LAUNCHER_USER_AGENT);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Content-Encoding", "amz-1.0");
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
int code = conn.getResponseCode();
String resp = AmazonAuthClient.readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
if (code < 200 || code >= 300) {
Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr) + ": " + (resp.length() > 200 ? resp.substring(0, 200) : resp));
return null;
}
return resp;
} catch (Exception e) {
Log.e(TAG, "POST failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
@@ -39,6 +39,8 @@ public final class AmazonAuthClient {
public String refreshToken;
public long expiresIn;
public String name;
/** The serial Amazon registered the device under; the entitlements call hashes this one. */
public String deviceSerial;
}
private AmazonAuthClient() {}
@@ -78,8 +80,12 @@ public final class AmazonAuthClient {
result.accessToken = bearer.getString("access_token");
result.refreshToken = bearer.getString("refresh_token");
result.expiresIn = bearer.optLong("expires_in", 3600L);
JSONObject customer = success.optJSONObject("extensions") != null ? success.getJSONObject("extensions").optJSONObject("customer_info") : null;
JSONObject ext = success.optJSONObject("extensions");
JSONObject customer = ext != null ? ext.optJSONObject("customer_info") : null;
result.name = customer != null ? customer.optString("name", "") : "";
JSONObject device = ext != null ? ext.optJSONObject("device_info") : null;
String registered = device != null ? device.optString("device_serial_number", "") : "";
result.deviceSerial = registered.isEmpty() ? deviceSerial : registered;
return result;
} catch (Exception e) {
Log.e(TAG, "registerDevice failed: " + e.getClass().getSimpleName());
@@ -75,10 +75,9 @@ object AmazonBackend : StoreBackend {
StoresState.shelves[store] = StoreShelves(trending = items.filter { it.id !in installed }, free = emptyList(), deals = emptyList(), whatsNew = emptyList())
}
override fun loadShelves(context: Context, force: Boolean) {
// Nothing public to fetch; the shelves follow the library sync.
if (!StoresState.library.containsKey(store)) syncLibrary(context, force)
}
// Nothing public to fetch; the shelves follow the library sync, which StoresState.open starts
// alongside this. A second sync here only came back Busy and cleared the first one's status.
override fun loadShelves(context: Context, force: Boolean) {}
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
@@ -119,7 +118,7 @@ object AmazonBackend : StoreBackend {
val exe = spec.exeRelative.ifEmpty { StoreExe.pick(folder, null, game.title) }
if (exe.isEmpty()) handle.log("amazon: no exe found in ${folder.name}; pick one in Steam settings › Added games")
val env = LinkedHashMap<String, String>()
for (kv in AmazonLaunchHelper.buildFuelEnv(game.entitlementId, game.productSku)) env[kv.substringBefore('=')] = kv.substringAfter('=')
for (kv in AmazonLaunchHelper.buildFuelEnv(game.entitlementId, game.productSku, com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, Store.AMAZON)?.takeIf { it != Store.AMAZON.label })) env[kv.substringBefore('=')] = kv.substringAfter('=')
val sidecar = StoreGameSidecar(
Store.AMAZON, game.productId, game.title, exe = exe.ifEmpty { "game.exe" }, args = spec.args, env = env,
installVersion = result.versionId ?: "", installedAt = System.currentTimeMillis(),
@@ -159,7 +158,7 @@ object AmazonBackend : StoreBackend {
val code = Uri.parse(url).getQueryParameter("openid.oa2.authorization_code")
if (code.isNullOrEmpty()) { Log.w(TAG, "login: return URL without a code"); return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification) }
val result = AmazonAuthClient.registerDevice(code, verifier, serial, clientId) ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
AmazonCredentialStore.save(activity, result.accessToken, result.refreshToken, serial, clientId, System.currentTimeMillis() + result.expiresIn * 1000L, result.name)
AmazonCredentialStore.save(activity, result.accessToken, result.refreshToken, result.deviceSerial ?: serial, clientId, System.currentTimeMillis() + result.expiresIn * 1000L, result.name)
Log.i(TAG, "signed in")
return null
}
@@ -34,6 +34,16 @@ object AmazonCredentialStore {
@JvmStatic
fun isLoggedIn(context: Context): Boolean = load(context) != null
/** A fresh access token whatever the stored expiry says: for a call Amazon refused with the stored one. */
@JvmStatic
fun refresh(context: Context): String? {
val creds = load(context) ?: return null
val result = AmazonAuthClient.refreshAccessToken(creds.refreshToken ?: return null) ?: return null
save(context, result.accessToken, creds.refreshToken, creds.deviceSerial, creds.clientId, System.currentTimeMillis() + result.expiresIn * 1000L, null)
Log.i(TAG, "token refreshed (forced)")
return result.accessToken
}
/** A valid access token, refreshed within five minutes of expiry; null when signed out or the refresh failed. */
@JvmStatic
fun getValidAccessToken(context: Context): String? {
@@ -66,13 +66,13 @@ public final class AmazonLaunchHelper {
}
/** The FuelPump variables, as KEY=VALUE, for the game's environment. */
public static String[] buildFuelEnv(String entitlementId, String productSku) {
public static String[] buildFuelEnv(String entitlementId, String productSku, String displayName) {
return new String[]{
"FUEL_DIR=C:\\ProgramData\\Amazon Games Services\\Legacy",
"AMAZON_GAMES_SDK_PATH=C:\\ProgramData\\Amazon Games Services\\AmazonGamesSDK",
"AMAZON_GAMES_FUEL_ENTITLEMENT_ID=" + entitlementId,
"AMAZON_GAMES_FUEL_PRODUCT_SKU=" + productSku,
"AMAZON_GAMES_FUEL_DISPLAY_NAME=Player",
"AMAZON_GAMES_FUEL_DISPLAY_NAME=" + (displayName == null || displayName.isEmpty() ? "Player" : displayName),
};
}
@@ -73,8 +73,11 @@ object AmazonLibrary {
val creds = AmazonCredentialStore.load(app) ?: return SyncResult.NotLoggedIn
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return SyncResult.Failed(StoresState.notSignedInLine(Store.AMAZON))
onStatus("Fetching game list…")
// Refused with the stored token (revoked, or an expiry read wrong): once more on a fresh one.
val all = AmazonApiClient.getEntitlements(token, creds.deviceSerial)
if (all.isNullOrEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
?: AmazonCredentialStore.refresh(app)?.let { AmazonApiClient.getEntitlements(it, creds.deviceSerial) }
if (all == null) return if (cachedList.isEmpty()) SyncResult.Failed("Amazon Games did not answer for your library: refresh, or sign in again") else SyncResult.Ok(cachedList)
if (all.isEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
val games = all.filter { !(it.isDLC && it.parentProductId.isNotEmpty()) }.ifEmpty { all }
.sortedWith { a, b -> a.title.compareTo(b.title, ignoreCase = true) }
for (fresh in games) cachedList.firstOrNull { it.productId == fresh.productId }?.let { fresh.versionId = it.versionId }
@@ -17,6 +17,7 @@ import android.os.PowerManager
import android.util.Log
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.formatBytes
import com.droiddeck.launcher.stores.formatSpeed
@@ -134,7 +135,7 @@ class StoreDownloadService : Service() {
private fun locks() {
val running = StoresState.latestDownloads.any { it.state == DownloadState.RUNNING }
if (!running) { releaseLocks(); return }
if (wifi == null) {
if (wifi == null && DeviceSupport.lowLatencyWifiSafe()) {
runCatching {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION")
@@ -156,7 +156,11 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
Store.EPIC, item.id, item.title, exe = exe.ifEmpty { "game.exe" }, args = args,
installVersion = result.buildVersion, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
// Both kept even when empty: the manifest was read, and names no prerequisite.
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId) + mapOf(
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_PATH to result.prereqPath,
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_NAME to result.prereqName,
) + (if (result.prereqRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
)
StoreInstalls.complete(app, folder, sidecar)
return folder.path
@@ -107,6 +107,11 @@ public final class EpicDownloadManager {
public String launchExe = "";
public String launchCommand = "";
public String buildVersion = "";
/** The prerequisite the launcher runs first (UE4PrereqSetup_x64.exe...): what it is called and its path in the install. */
public String prereqName = "";
public String prereqPath = "";
/** The prerequisite fields were read (an empty name and path then mean the build names none). */
public boolean prereqRead = false;
public List<CdnUrl> cdnUrls = new ArrayList<>();
}
@@ -115,7 +120,12 @@ public final class EpicDownloadManager {
public final String launchExe;
public final String buildVersion;
public final long bytes;
/** The manifest's prerequisite, for Windows components; "" when it names none. */
public String prereqName = "";
public String prereqPath = "";
Result(String launchExe, String buildVersion, long bytes) { this.launchExe = launchExe; this.buildVersion = buildVersion; this.bytes = bytes; }
public boolean prereqRead = false;
Result withPrereq(Manifest m) { prereqName = m.prereqName; prereqPath = m.prereqPath; prereqRead = m.prereqRead; return this; }
}
public static final class InstallException extends Exception {
@@ -178,7 +188,7 @@ public final class EpicDownloadManager {
if (pending.isEmpty()) {
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
}
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
@@ -265,7 +275,7 @@ public final class EpicDownloadManager {
if (r.cancelled) return null;
if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
}
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
}
@@ -293,7 +303,7 @@ public final class EpicDownloadManager {
}
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
} catch (InstallException e) {
throw e;
} catch (Exception e) {
@@ -470,6 +480,13 @@ public final class EpicDownloadManager {
m.buildVersion = readFString(b);
m.launchExe = readFString(b).replace('\\', '/');
m.launchCommand = readFString(b);
// PrereqIds (an array), PrereqName, PrereqPath, PrereqArgs.
int prereqIds = b.getInt();
for (int i = 0; i < prereqIds && i < 64; i++) readFString(b);
m.prereqName = readFString(b);
m.prereqPath = readFString(b).replace('\\', '/');
readFString(b); // PrereqArgs
m.prereqRead = true;
} catch (Exception e) {
Log.w(TAG, "manifest meta: " + e.getClass().getSimpleName());
}
@@ -537,6 +554,9 @@ public final class EpicDownloadManager {
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
m.launchCommand = root.optString("LaunchCommand", "");
m.buildVersion = root.optString("BuildVersionString", "");
m.prereqName = root.optString("PrereqName", "");
m.prereqPath = root.optString("PrereqPath", "").replace('\\', '/');
m.prereqRead = true;
JSONObject chunkHashList = root.optJSONObject("ChunkHashList");
JSONObject dataGroupList = root.optJSONObject("DataGroupList");
JSONObject chunkFilesizeList = root.optJSONObject("ChunkFilesizeList");
@@ -753,6 +773,22 @@ public final class EpicDownloadManager {
}
}
/** The manifest's prerequisite as {name, path}, from the manifest alone; null on failure. Blocking. */
public static String[] fetchPrereq(String manifestApiJson) {
try {
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
if (cdnUrls.isEmpty()) return null;
byte[] bytes = downloadManifest(manifestApiJson, cdnUrls);
if (bytes == null) return null;
Manifest m = parseManifest(bytes);
if (m == null || !m.prereqRead) { Log.w(TAG, "manifest prerequisite not read"); return null; }
Log.i(TAG, "manifest " + m.buildVersion + ": " + m.files.size() + " files, prerequisite " + (m.prereqName.isEmpty() && m.prereqPath.isEmpty() ? "none" : m.prereqName + " (" + m.prereqPath + ")"));
return new String[] { m.prereqName, m.prereqPath };
} catch (Exception e) {
return null;
}
}
/** The install size for the tags (null = every file), from the manifest alone; -1 on failure. Blocking. */
public static long fetchInstallSizeBytes(String manifestApiJson, List<String> installTags) {
try {
@@ -111,6 +111,9 @@ object GogBackend : StoreBackend {
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
// Kept even when empty: the build was read, and lists none.
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
)
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
@@ -79,6 +79,10 @@ public final class GogDownloadManager {
public final String exeRelative;
public final String buildId;
public final long bytes;
/** The build manifest's "dependencies" (GOG's redistributable ids: MSVC2017_x64, DirectX...), for Windows components. */
public List<String> dependencies = new ArrayList<>();
/** The dependencies come from a gen2 build manifest read whole (gen1 builds and installers have none). */
public boolean dependenciesRead = false;
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
}
@@ -150,10 +154,12 @@ public final class GogDownloadManager {
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
baseProductId = products.getJSONObject(0).optString("productId", null);
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
// The build's required redistributables (MSVC, DirectX, ...) are recorded for the engine
// log; Steam's shared redistributables run in the prefix on first launch as for any shortcut.
// The build's required redistributables (MSVC, DirectX, ...): logged, and kept in the
// sidecar, where Windows components take them as this game's list (WinCompSources).
JSONArray deps = manifest.optJSONArray("dependencies");
if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
List<String> dependencies = new ArrayList<>();
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
cb.onProgress("Reading depot manifests…", 10);
List<DepotFile> files = new ArrayList<>();
@@ -262,6 +268,8 @@ public final class GogDownloadManager {
String clientSecret = manifest.optString("clientSecret", null);
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
out.result.dependencies = dependencies;
out.result.dependenciesRead = true;
return out;
} catch (Exception e) {
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
@@ -1031,6 +1039,48 @@ public final class GogDownloadManager {
}
/** The install size of the Windows build (English / all-language depots), or -1. Blocking. */
/**
* The "dependencies" of the build [buildId] of [gameId] (else the newest Windows build), from
* its build manifest alone: no game file is fetched. Null when the builds or the manifest
* could not be had; empty when the build lists none. Blocking.
*/
public static List<String> fetchDependencies(String gameId, String buildId, String token) {
try {
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
String buildsJson = httpGet(buildsUrl, null);
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
if (buildsJson == null) return null;
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
if (items == null || items.length() == 0) return null;
String manifestUrl = null;
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.getJSONObject(i);
if (!"windows".equals(item.optString("os"))) continue;
String link = item.optString("link");
if (link == null || link.isEmpty()) link = item.optString("meta_url");
if (manifestUrl == null) manifestUrl = link;
if (buildId != null && !buildId.isEmpty() && buildId.equals(item.optString("build_id"))) { manifestUrl = link; break; }
}
if (manifestUrl == null || manifestUrl.isEmpty()) return null;
byte[] raw = fetchBytes(manifestUrl, token);
if (raw == null) return null;
String manifestStr = decompressBytes(raw);
if (manifestStr == null) return null;
JSONObject manifest = new JSONObject(manifestStr);
// A manifest without depots is not one this reads right: no list rather than an empty one.
JSONArray depots = manifest.optJSONArray("depots");
if (depots == null) { Log.w(TAG, "gog: build manifest for " + gameId + " has no depots; keys " + manifest.names()); return null; }
JSONArray deps = manifest.optJSONArray("dependencies");
List<String> out = new ArrayList<>();
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) out.add(d); }
Log.i(TAG, "gog: build " + manifest.optString("buildId", buildId) + " of " + gameId + ": " + depots.length() + " depots, dependencies "
+ (manifest.has("dependencies") ? String.valueOf(deps) : "(none listed)"));
return out;
} catch (Exception e) {
return null;
}
}
public static long fetchInstallSizeBytes(String gameId, String token) {
try {
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
@@ -41,6 +41,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -313,12 +314,17 @@ internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolea
@Composable
internal fun AppDialog(
shown: MutableTransitionState<Boolean>, close: () -> Unit, label: String, wide: Boolean,
modifier: Modifier = Modifier, maxWidth: Dp? = null, content: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier, maxWidth: Dp? = null,
/** [width] fixed, else as wide as its content when [fitContent], else as wide as it may be. */
width: Dp? = null, fitContent: Boolean = false,
/** What B / Back does, when not [close] (a picker goes up a folder first). */
back: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val pal = LocalPalette.current
// The page's ring stays out of sight behind it.
VeilRing()
Dialog(onDismissRequest = close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
Dialog(onDismissRequest = back ?: close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
// The page's focus memory stays the page's: nothing in here is a place to come back to.
CompositionLocalProvider(LocalFrontFocus provides null) {
Box(
@@ -337,14 +343,20 @@ internal fun AppDialog(
modifier = Modifier
.padding(12.dp)
.widthIn(max = maxWidth ?: if (wide) 860.dp else 560.dp)
.fillMaxWidth()
.then(
when {
width != null -> Modifier.width(width)
fitContent -> Modifier.width(androidx.compose.foundation.layout.IntrinsicSize.Max)
else -> Modifier.fillMaxWidth()
},
)
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 24).dp)
.shadow(24.dp, Shape16, ambientColor = Color.Black, spotColor = Color.Black)
.clip(Shape16)
.background(pal.surfaceVariant.copy(alpha = 0.97f))
.border(1.dp, pal.signal.copy(alpha = 0.22f), Shape16)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.controllerBack(close)
.controllerBack(back ?: close)
.then(modifier)
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
@@ -0,0 +1,555 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AddedGameArt
import com.droiddeck.launcher.frontend.AddedGameEdits
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/** Each art slot's thumbnail, in its true shape: width, height, corner. */
private fun AddedGameArt.Slot.shape(): Triple<Dp, Dp, Dp> = when (this) {
AddedGameArt.Slot.COVER -> Triple(28.dp, 41.dp, 5.dp)
AddedGameArt.Slot.BACKGROUND -> Triple(60.dp, 34.dp, 5.dp)
AddedGameArt.Slot.LOGO -> Triple(60.dp, 26.dp, 5.dp)
AddedGameArt.Slot.ICON -> Triple(30.dp, 30.dp, 7.dp)
}
@Composable
private fun AddedGameArt.Slot.label(): String = stringResource(
when (this) {
AddedGameArt.Slot.COVER -> R.string.art_cover
AddedGameArt.Slot.BACKGROUND -> R.string.art_background
AddedGameArt.Slot.LOGO -> R.string.art_logo
AddedGameArt.Slot.ICON -> R.string.art_icon
},
)
@Composable
private fun artSourceLabel(source: String): String = stringResource(
when (source) {
AddedGameArt.FOLDER -> R.string.art_src_folder
AddedGameArt.STEAM -> R.string.art_src_steam
AddedGameArt.SGDB -> R.string.art_src_sgdb
AddedGameArt.FILE -> R.string.art_src_file
AddedGameArt.EXE -> R.string.art_src_exe
else -> R.string.art_src_auto
},
)
/** A thumbnail of [model] (a file or URL) in [slot]'s shape, scaled by [scale]. */
@Composable
private fun SlotThumb(slot: AddedGameArt.Slot, model: Any?, scale: Float = 1f, selected: Boolean = false) {
val pal = LocalPalette.current
val (w, h, r) = slot.shape()
val shape = RoundedCornerShape(r)
Box(
Modifier.size(w * scale, h * scale).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant)
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape),
) {
if (model != null) AsyncImage(
model = model, contentDescription = null,
contentScale = if (slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON) ContentScale.Fit else ContentScale.Crop,
modifier = Modifier.matchParentSize(),
)
}
}
/** A row of the editor: a label column, then the value. */
@Composable
private fun FieldRow(label: String, focus: FocusRequester? = null, onClick: () -> Unit, value: @Composable () -> Unit) {
ListRow(focus = focus, onClick = onClick) {
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(110.dp))
Box(Modifier.weight(1f)) { value() }
}
}
/** Where the editor opens: its game's folder, straight on Target, and whether it came from the added-games summary. */
internal class EditRequest(val folder: String, val openTarget: Boolean, val fromSummary: Boolean, val preview: EditPreview)
/**
* What the editor shows on its first frame, from what the page already has (the game's row, or
* the summary's); the rest (the ranked exes, where each piece of art came from) follows.
* [exeName] / [startIn] null = not known yet.
*/
internal class EditPreview(
val name: String, val exeName: String?, val startIn: String?, val launchOptions: String,
val art: Map<AddedGameArt.Slot, File?>,
) {
companion object {
/** From the Games tab's row for [game] and the few preferences the edits keep. */
fun of(context: android.content.Context, game: com.droiddeck.launcher.frontend.Library.SteamGame, folder: String): EditPreview {
val exe = SessionPrefs.addedGameExe(context, folder).ifEmpty {
com.droiddeck.launcher.frontend.AddedExes.list(context).firstOrNull { it.folder == folder && it.picked }?.exe.orEmpty()
}.takeIf { it.isNotEmpty() }?.let { File(it) }
val startIn = SessionPrefs.addedGameStartIn(context, folder).takeIf { it.isNotEmpty() }?.substringAfterLast('/') ?: exe?.parentFile?.name
return EditPreview(
game.name, exe?.name, startIn, SessionPrefs.addedGameLaunch(context, folder),
mapOf(AddedGameArt.Slot.COVER to game.art, AddedGameArt.Slot.BACKGROUND to game.hero, AddedGameArt.Slot.ICON to game.icon),
)
}
/** From a summary row's game and its cover. */
fun of(game: AddedGames.Game, cover: File?) = EditPreview(
game.name, game.exe.name, game.guestDir.substringAfterLast('/'), game.launchOptions, mapOf(AddedGameArt.Slot.COVER to cover),
)
}
}
/** A small spinner where a value is still being read. */
@Composable
private fun Loading() {
androidx.compose.material3.CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
}
/**
* An added game's editor, Steam's non-Steam Properties in the app: Name, Target (the ranked exes,
* "?" where nothing clearly won, Other exe…), Start in, Launch options, the four pieces of art, and
* Remove (two presses; the files stay). Every change goes through the game's own Steam shortcut.
*/
@Composable
internal fun AddedGameEditor(
request: EditRequest, onBack: () -> Unit, onClose: () -> Unit, onChanged: () -> Unit,
/** Remove pressed twice: the game's folder, its shortcut appid when known, its name. */
onRemove: (folder: String, appId: Long?, name: String) -> Unit,
) {
val ctx = LocalContext.current
val app = ctx.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
val folder = request.folder
var version by remember { mutableIntStateOf(0) }
var game by remember { mutableStateOf<AddedGames.Game?>(null) }
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
var ranked by remember { mutableStateOf<List<File>?>(null) }
var targetOpen by remember { mutableStateOf(request.openTarget) }
var armed by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<String?>(null) }
var otherExe by remember { mutableStateOf(false) }
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
var unreachable by remember { mutableStateOf(false) }
LaunchedEffect(version) {
withContext(Dispatchers.IO) {
val g = AddedGameEdits.game(app, folder)
game = g
if (g != null) {
ranked = g.candidates.let { list -> if (list.any { it.path == g.exe.path }) list else listOf(g.exe) + list }
arts = AddedGameArt.describe(app, g)
}
}
}
LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4_000); armed = false } }
val change: (() -> Unit) -> Unit = { work ->
scope.launch {
withContext(Dispatchers.IO) { work() }
version++
onChanged()
}
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
val preview = request.preview
AppDialog(shown, close, "gameEditor", wide = false, maxWidth = 460.dp) {
val g = game
val name = g?.name ?: preview.name
DialogTitleRow(
name, onClose = close,
leading = if (request.fromSummary) ({ SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground) { onBack(); close() } }) else null,
)
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
FieldRow(stringResource(R.string.edit_name), focus = first, onClick = { editing = "name" }) { ValueText(name) }
FieldRow(stringResource(R.string.edit_target), onClick = { targetOpen = !targetOpen }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val exeName = g?.exe?.name ?: preview.exeName
Box(Modifier.weight(1f)) { if (exeName != null) ValueText(exeName) else Loading() }
if (g == null && exeName != null) Loading()
if (g?.exeUncertain == true) UncertainMark(stringResource(R.string.mode_added_exe_check))
}
}
if (targetOpen) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(start = 16.dp)) {
val list = ranked
if (g == null || list == null) Box(Modifier.padding(8.dp)) { Loading() }
else for (exe in list) key(exe.path) {
val rel = exe.relativeToOrSelf(g.folder).invariantSeparatorsPath
PickerRow(null, exe.name, rel.substringBeforeLast('/', "").ifEmpty { null }, accent = exe.path == g.exe.path) {
targetOpen = false
change { AddedGameEdits.setExe(app, folder, exe) }
}
}
PickerRow(null, stringResource(R.string.edit_other_exe), null, enabled = g != null) { otherExe = true }
}
}
FieldRow(stringResource(R.string.edit_start_in), onClick = { if (g != null) editing = "start" }) {
val startIn = g?.guestDir?.substringAfterLast('/') ?: preview.startIn
if (startIn != null) ValueText(startIn) else Loading()
}
val launch = g?.launchOptions ?: preview.launchOptions
FieldRow(stringResource(R.string.edit_launch_options), onClick = { editing = "launch" }) { ValueText(launch.ifEmpty { "—" }, muted = launch.isEmpty()) }
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 8.dp))
for (slot in AddedGameArt.Slot.values()) key(slot) {
val state = arts.firstOrNull { it.slot == slot }
FieldRow(slot.label(), onClick = { if (g != null) artSlot = slot }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SlotThumb(slot, if (state != null) state.file else preview.art[slot])
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
}
}
}
RemoveRow(armed) {
if (!armed) armed = true else {
armed = false
// At once: the page drops the game and the rest runs off this dialog's life.
onRemove(folder, game?.appId, game?.name ?: preview.name)
if (request.fromSummary) onBack()
close()
}
}
}
if (unreachable) Text(stringResource(R.string.games_add_unreachable), fontSize = 12.sp, color = colors.error)
}
val shownName = game?.name ?: preview.name
when (editing) {
"name" -> TextEntryDialog(stringResource(R.string.edit_name), SessionPrefs.addedGameName(ctx, folder).ifEmpty { shownName }, onDismiss = { editing = null }) { value ->
change { AddedGameEdits.setName(app, folder, if (value == shownName && SessionPrefs.addedGameName(app, folder).isEmpty()) "" else value) }
}
"launch" -> TextEntryDialog(stringResource(R.string.edit_launch_options), game?.launchOptions ?: preview.launchOptions, onDismiss = { editing = null }) { value ->
change { AddedGameEdits.setLaunchOptions(app, folder, value) }
}
}
val g = game ?: return
when (editing) {
"start" -> TextEntryDialog(stringResource(R.string.edit_start_in), hostPath(ctx, g), onDismiss = { editing = null }) { value ->
scope.launch {
val ok = withContext(Dispatchers.IO) { AddedGameEdits.setStartIn(app, folder, if (value == g.exe.parentFile?.path) "" else value) }
unreachable = !ok
version++
onChanged()
}
}
}
if (otherExe) ExePickerDialog(stringResource(R.string.games_pick_exe_title), start = g.folder, onDismiss = { otherExe = false }, onPick = { exe ->
targetOpen = false
scope.launch {
val ok = withContext(Dispatchers.IO) { AddedGameEdits.setExe(app, folder, exe) }
unreachable = !ok
version++
onChanged()
}
})
artSlot?.let { slot ->
val current = arts.firstOrNull { it.slot == slot }
ArtChooserDialog(g, slot, current, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
}
}
/** Start in as a path on the device: the override mapped back under the game's folder, else the exe's folder. */
private fun hostPath(context: android.content.Context, game: AddedGames.Game): String {
val folderGuest = AddedGames.guestPath(context, game.folder) ?: return game.exe.parentFile?.path.orEmpty()
return when {
game.guestDir == folderGuest -> game.folder.path
game.guestDir.startsWith("$folderGuest/") -> game.folder.path + game.guestDir.removePrefix(folderGuest)
else -> game.exe.parentFile?.path.orEmpty()
}
}
@Composable
private fun ValueText(text: String, muted: Boolean = false) {
Text(
text, fontSize = 14.sp, color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
/** "Remove" in red; pressed once it asks, a second press within four seconds removes. */
@Composable
private fun RemoveRow(armed: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
val hot = rememberHot(src)
Box(
Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape12)
.background(if (armed) colors.error.copy(alpha = 0.18f) else if (hot) colors.error.copy(alpha = 0.10f) else androidx.compose.ui.graphics.Color.Transparent)
.glideBorder(hot, Shape12, colors.error, colors.error.copy(alpha = 0.4f))
.hoverable(src)
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
stringResource(if (armed) R.string.mode_added_remove_confirm else R.string.edit_remove),
fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error,
)
}
}
/** One line of text in a compact dialog: [title] and a red X, the field, OK. */
@Composable
internal fun TextEntryDialog(title: String, initial: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var value by remember { mutableStateOf(initial) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus } }
AppDialog(shown, close, "textEntry", wide = false, maxWidth = 460.dp) {
DialogTitleRow(title, onClose = close)
androidx.compose.material3.OutlinedTextField(
value, { value = it }, singleLine = true, modifier = Modifier.fillMaxWidth().focusRequester(focus),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onSave(value.trim()); close() }),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Done),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
SecondaryButton(stringResource(R.string.common_ok), compact = true) { onSave(value.trim()); close() }
}
}
}
/**
* The art chooser for one [slot]: back, the slot's name and a red X; the images grouped by where
* they come from (the game's folder, Steam, SteamGridDB, fetched as it opens), the current one
* outlined, a tap applies it; "Pick image…" and "Reset to automatic".
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ArtChooserDialog(
game: AddedGames.Game, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
onBack: () -> Unit, onCloseAll: () -> Unit, onChanged: () -> Unit,
) {
val ctx = LocalContext.current
val app = ctx.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
val shown = rememberShown(onBack)
val back = { shown.targetState = false }
var options by remember { mutableStateOf<List<AddedGameArt.Option>?>(null) }
var picking by remember { mutableStateOf(false) }
val chosenRef = remember(current) { AddedGameArt.chosenSource(app, game, slot).second }
LaunchedEffect(slot) { options = withContext(Dispatchers.IO) { runCatching { AddedGameArt.options(app, game, slot) }.getOrDefault(emptyList()) } }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
val apply: (AddedGameArt.Option) -> Unit = { option ->
scope.launch {
withContext(Dispatchers.IO) { AddedGameEdits.setArt(app, game.folder.path, slot, option) }
onChanged()
back()
}
}
val slotName = slot.label()
AppDialog(shown, back, "artChooser", wide = false, maxWidth = 460.dp) {
DialogTitleRow(slotName, onClose = { onCloseAll() }, leading = {
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
})
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
val opts = options
if (opts == null) {
androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
} else {
var firstGiven = false
for (group in listOf(AddedGameArt.FOLDER, AddedGameArt.STEAM, AddedGameArt.SGDB)) {
val inGroup = opts.filter { it.group == group }
if (inGroup.isEmpty()) continue
Text(artSourceLabel(group), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (option in inGroup) key(option.full) {
val focus = if (!firstGiven) first.also { firstGiven = true } else null
ArtOption(slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = focus, description = "${artSourceLabel(group)} $slotName") { apply(option) }
}
}
}
}
PickerRow(Icons.Outlined.Image, stringResource(R.string.art_pick_image), null, focus = if (opts?.isEmpty() != false) first else null) { picking = true }
PickerRow(null, stringResource(R.string.art_reset), null) {
scope.launch {
withContext(Dispatchers.IO) { AddedGameEdits.resetArt(app, game.folder.path, slot) }
onChanged()
back()
}
}
}
}
if (picking) ExePickerDialog(stringResource(R.string.art_pick_title, slotName), kind = PickKind.IMAGE, onDismiss = { picking = false }, onPick = { file ->
apply(AddedGameArt.Option(AddedGameArt.FILE, file.path, file.path))
})
}
@Composable
private fun ArtOption(slot: AddedGameArt.Slot, option: AddedGameArt.Option, selected: Boolean, focus: FocusRequester?, description: String, onClick: () -> Unit) {
val pal = LocalPalette.current
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
val hot = rememberHot(src)
val scale = when (slot) { AddedGameArt.Slot.COVER -> 1.9f; AddedGameArt.Slot.ICON -> 1.6f; else -> 1.5f }
Box(
Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(RoundedCornerShape(8.dp))
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = description }
.padding(2.dp),
) {
SlotThumb(slot, if (option.thumb.startsWith("https://")) option.thumb else File(option.thumb), scale, selected)
}
}
/**
* After "Add all games in this folder": how many were added and how many were there already, then
* one row per added game (cover, name, exe, "?" where unsure); a row opens that game's editor.
* [games] are the added games as the add left them, shown at once; the rows then follow edits and
* removals, and the covers arrive.
*/
@Composable
internal fun AddedGamesSummaryDialog(
games: List<AddedGames.Game>, already: Int, checking: String? = null,
onOpen: (folder: String, uncertain: Boolean, preview: EditPreview) -> Unit,
onRemove: (folder: String, appId: Long?, name: String) -> Unit,
onDismiss: () -> Unit,
) {
val ctx = LocalContext.current
val app = ctx.applicationContext
val colors = MaterialTheme.colorScheme
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var rows by remember { mutableStateOf(games.map { it to (null as File?) }) }
var gone by remember { mutableStateOf(emptySet<String>()) }
var armed by remember { mutableStateOf<String?>(null) }
LaunchedEffect(armed) { if (armed != null) { kotlinx.coroutines.delay(4_000); armed = null } }
// Rows show as the games arrive; each is refreshed once (current name and exe, a removed one
// dropped) and gets its cover, without redoing the ones already done when more arrive.
var resolved by remember { mutableStateOf(emptyMap<String, Pair<AddedGames.Game, File?>?>()) }
LaunchedEffect(games) {
for (old in games) {
val path = old.folder.path
if (path in resolved) continue
val fresh = withContext(Dispatchers.IO) { AddedGames.single(app, path)?.let { g -> g to AddedGameArt.resolve(app, g).portrait } }
resolved = resolved + (path to fresh)
}
}
LaunchedEffect(games, resolved) {
rows = games.mapNotNull { g -> if (g.folder.path in resolved) resolved[g.folder.path] else g to null }
}
val first = remember { FocusRequester() }
val list = rows.filter { it.first.folder.path !in gone }
LaunchedEffect(list.isNotEmpty()) { if (list.isNotEmpty()) focusWithinFrames({ false }) { first } }
AppDialog(shown, close, "addedSummary", wide = false, maxWidth = 440.dp) {
DialogTitleRow(
if (list.isEmpty() && checking == null) stringResource(R.string.games_added_none) else pluralStringResource(R.plurals.games_added_n, list.size, list.size),
onClose = close,
)
if (already > 0) Text(pluralStringResource(R.plurals.games_already_n, already, already), fontSize = 12.sp, color = colors.onSurfaceVariant)
if (checking != null) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = colors.primary)
Text(checking, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.7f).dp
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
list.forEachIndexed { i, (g, cover) ->
key(g.folder.path) {
ListRow(focus = if (i == 0) first else null, onClick = { onOpen(g.folder.path, g.exeUncertain, EditPreview.of(g, cover)); close() }) {
SlotThumb(AddedGameArt.Slot.COVER, cover, 0.9f)
Column(Modifier.weight(1f)) {
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(g.exe.name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (g.exeUncertain) UncertainMark(stringResource(R.string.mode_added_exe_check))
val path = g.folder.path
RemoveX(armed == path, stringResource(R.string.summary_remove_game, g.name)) {
if (armed != path) armed = path else {
armed = null
gone = gone + path
onRemove(path, g.appId, g.name)
}
}
}
}
}
}
}
}
/** A summary row's red X: the first press arms it (filled), a second within 4 s removes the game. */
@Composable
private fun RemoveX(armed: Boolean, description: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
val hot = rememberHot(src)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(32.dp).clip(Shape12)
.background(if (armed) colors.error else if (hot) colors.error.copy(alpha = 0.15f) else androidx.compose.ui.graphics.Color.Transparent)
.glideBorder(hot, Shape12, colors.error)
.hoverable(src)
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = description },
) {
androidx.compose.material3.Icon(Icons.Filled.Close, null, tint = if (armed) colors.onError else colors.error, modifier = Modifier.size(18.dp))
}
}
@@ -28,6 +28,7 @@ class ControllerActions(
val onSteamButton: (Boolean) -> Unit,
val onQamButton: (Boolean) -> Unit,
val onKeyboardButton: (Boolean) -> Unit,
val onSelectSteam: (Boolean) -> Unit,
val onEditLayout: () -> Unit,
val onResetLayout: () -> Unit,
val onMapping: () -> Unit,
@@ -43,7 +44,7 @@ private fun Swatch(color: Int) {
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
ChoiceRow(
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
@@ -70,6 +71,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
@@ -15,10 +15,16 @@ import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.min
/**
@@ -26,7 +32,8 @@ import kotlin.math.min
*
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
* row beneath. Given the [pad] (the unfolded screen's panel), a fifth: a whole gamepad, each side
* an analog stick or a trackpad as the player picks. A trackpad clicks from its bar or from a second finger while the first is down.
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
* where something is being touched.
*/
@@ -36,18 +43,29 @@ class DeckControlsPanel(
private val onClose: () -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
private val pad: PadBridge? = null,
) : LinearLayout(context) {
private enum class Tab(val label: Int) { GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
private val content = FrameLayout(context)
private val tabViews = HashMap<Tab, TextView>()
private val topBar: View by lazy { topBar() }
init {
orientation = VERTICAL
setBackgroundColor(Color.BLACK)
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
select(selected)
select(if (pad != null) selectedWithPad else selected)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// Held flat the gamepad has no Panels button, so another tab would have no way back:
// turning the phone from upright onto its side returns to the gamepad.
if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) {
post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) }
}
}
override fun onDetachedFromWindow() {
@@ -65,6 +83,7 @@ class DeckControlsPanel(
background = outline(CORNER_PILL, SURFACE)
setPadding(dp(4), dp(4), dp(4), dp(4))
for (tab in Tab.values()) {
if (tab == Tab.GAMEPAD && pad == null) continue
val view = TextView(context).apply {
text = context.getString(tab.label)
textSize = 14f
@@ -88,7 +107,7 @@ class DeckControlsPanel(
}
private fun select(tab: Tab) {
selected = tab
if (pad != null) selectedWithPad = tab else selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
@@ -96,12 +115,15 @@ class DeckControlsPanel(
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
content.removeAllViews()
// The gamepad needs every row of the panel; held upright its Panels button brings the tabs back.
topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE
content.addView(
when (tab) {
Tab.GRIPS -> gripsGrid()
Tab.LEFT -> singlePad(right = false)
Tab.RIGHT -> singlePad(right = true)
Tab.BOTH -> bothPads()
Tab.GAMEPAD -> gamepad(pad ?: return)
},
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
)
@@ -151,6 +173,79 @@ class DeckControlsPanel(
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
}
/**
* A whole pad, placed for two thumbs rather than in columns (see [GamepadGeometry]): on each side
* a surface where the thumb rests - most of play is on the sticks or trackpads - and a short
* slide inward along its arc, the d-pad on the left and the face buttons on the right; the bumpers and triggers above, at the top of the arc; View, Steam, QAM and Menu in the
* middle, which a thumb only reaches on purpose. Each surface is an analog stick or a Steam
* trackpad, switched from its corner and remembered (SessionPrefs.gamepadSurface). Its tab bar
* is hidden; held upright, a Panels button brings the other tabs back, and held flat there is no
* room for one.
*/
private fun gamepad(pad: PadBridge): View = GamepadLayout(context).apply {
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM)
place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM)
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
GamepadGeometry.Slot.PANELS)
}
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
text = label
contentDescription = description
textSize = 13f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = 0.08f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(CORNER_PILL, SURFACE)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
/** One side's stick or trackpad, with a chip in its outer corner that swaps the two. */
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
val slot = this
fun show() {
slot.removeAllViews()
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
// No caption: the switch in its corner says which it is.
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
val chip = TextView(context).apply {
text = "\u21c4"
contentDescription = context.getString(R.string.deck_surface_switch)
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
setTextColor(TEXT_DIM)
background = outline(CORNER_PILL, SURFACE)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
SessionPrefs.setGamepadSurface(context, right,
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
show()
}
}
// In the corner away from the thumb, so play does not swap it by accident.
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
}
show()
}
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
TextView(context).apply {
text = label
@@ -262,7 +357,7 @@ class DeckControlsPanel(
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
* while a second finger is down.
*/
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
@@ -283,7 +378,7 @@ class DeckControlsPanel(
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
if (touching) {
val radius = 56 * density
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
@@ -333,9 +428,271 @@ class DeckControlsPanel(
}
}
/** A pad button held while a finger is on it, written into the shared pad as the on-screen pad does. */
private class PadButton(
context: Context,
private val pad: PadBridge,
label: String,
private val write: (PadState, Boolean) -> Unit,
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
override fun onDetachedFromWindow() {
pad.applyTouch { write(it, false) }
super.onDetachedFromWindow()
}
}
/**
* An analog stick that centres where the thumb lands, so it is never missed: the knob follows
* the finger from there out to the ring. Tapping it again and holding (within [CLICK_MS]) is
* the stick's click - L3 or R3 - for as long as the thumb stays down.
*/
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicking = false
private var lastUp = 0L
private var originX = 0f
private var originY = 0f
private var knobX = 0f
private var knobY = 0f
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
private fun reach() = min(width, height) * 0.30f
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicking)
val cx = if (touching) originX else width / 2f
val cy = if (touching) originY else height / 2f
ring.strokeWidth = 2 * density
ring.color = if (touching) ACCENT else LINE
canvas.drawCircle(cx, cy, reach(), ring)
knob.color = if (touching) ACCENT else KNOB
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val r = reach().coerceAtLeast(1f)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// Centred where the thumb lands, but kept far enough in for the ring to fit.
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
touching = true
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
follow(event.x, event.y, r)
}
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
touching = false
lastUp = event.eventTime
setClick(false)
move(0f, 0f, 1f)
}
}
return true
}
private fun follow(x: Float, y: Float, r: Float) {
var dx = x - originX
var dy = y - originY
val length = hypot(dx, dy)
if (length > r) { dx *= r / length; dy *= r / length }
move(dx, dy, r)
}
private fun move(dx: Float, dy: Float, r: Float) {
knobX = dx
knobY = dy
pad.applyTouch { s ->
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
}
invalidate()
}
private fun setClick(down: Boolean) {
if (clicking == down) return
clicking = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
}
override fun onDetachedFromWindow() {
setClick(false)
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
super.onDetachedFromWindow()
}
}
/** The d-pad: the direction is the finger's angle from the centre, diagonals included. */
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
private val density = resources.displayMetrics.density
private var held = 0 // bits: 1 up, 2 right, 4 down, 8 left
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
val size = min(width, height) * 0.86f
val cx = width / 2f
val cy = height / 2f
val half = size / 2f
val thick = size / 3f / 2f
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
arm.color = if (held and bit != 0) ACCENT else KNOB
rect.set(l, t, r, b)
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
}
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
arm.color = KNOB
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val bits = when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
val dx = event.getX(0) - width / 2f
val dy = event.getY(0) - height / 2f
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
// Eight sectors of 45 degrees, starting with right and going clockwise.
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
}
}
else -> 0
}
set(bits)
return true
}
private fun set(bits: Int) {
if (bits == held) return
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
held = bits
pad.applyTouch { s ->
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
}
invalidate()
}
override fun onDetachedFromWindow() {
set(0)
super.onDetachedFromWindow()
}
}
/** A, B, X and Y in a diamond, as on a Deck; several fingers can hold several at once. */
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
private var held = emptySet<Int>()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
/** Top, right, bottom, left - the centre of each, and the radius of all. */
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
val cx = width / 2f
val cy = height / 2f
val spread = min(width, height) * 0.30f
val radius = min(width, height) * 0.16f
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
}
override fun onDraw(canvas: Canvas) {
val (centres, radius) = layout()
text.textSize = radius * 0.9f
buttons.forEachIndexed { i, (button, label) ->
val (x, y) = centres[i]
val down = button in held
fill.color = if (down) ACCENT else KNOB
canvas.drawCircle(x, y, radius, fill)
text.color = if (down) ON_ACCENT else TEXT
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val (centres, radius) = layout()
val now = HashSet<Int>()
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
if (!ending) for (p in 0 until event.pointerCount) {
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
val x = event.getX(p)
val y = event.getY(p)
// The nearest button within reach, so a thumb between two still lands on one.
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
}
set(now)
return true
}
private fun set(now: Set<Int>) {
if (now == held) return
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
val before = held
held = now
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
invalidate()
}
override fun onDetachedFromWindow() {
set(emptySet())
super.onDetachedFromWindow()
}
}
/** Lays its controls out where [GamepadGeometry] puts them for the panel's size. */
private class GamepadLayout(context: Context) : ViewGroup(context) {
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
fun place(view: View, slot: GamepadGeometry.Slot) {
slots[view] = slot
addView(view)
}
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(w, h)
val boxes = boxes(w, h)
for ((view, slot) in slots) {
val box = boxes[slot]
view.visibility = if (box == null) View.GONE else View.VISIBLE
if (box == null) continue
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val boxes = boxes(r - l, b - t)
for ((view, slot) in slots) {
val box = boxes[slot] ?: continue
val left = box.left.toInt()
val top = box.top.toInt()
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
}
}
}
companion object {
/** A second press on a stick this soon after the last is its click (L3, R3). */
private const val CLICK_MS = 300L
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
private var selected = Tab.BOTH
/** The same for the unfolded screen's panel, which opens on the gamepad. */
private var selectedWithPad = Tab.GAMEPAD
private const val CORNER = 20f
private const val CORNER_PILL = 22f
@@ -348,5 +705,6 @@ class DeckControlsPanel(
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
}
}
@@ -0,0 +1,234 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.os.Environment
import android.os.StatFs
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.InsertDriveFile
import androidx.compose.material.icons.outlined.SdCard
import androidx.compose.material.icons.outlined.Smartphone
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.stores.formatBytes
import java.io.File
/** What a picker lists besides folders. */
internal enum class PickKind(val extensions: Set<String>) {
EXE(setOf("exe")),
IMAGE(setOf("png", "jpg", "jpeg", "webp", "ico")),
}
/** A place a file can be picked from: internal storage or a card, at its root. */
internal class PickVolume(val root: File, val label: String, val removable: Boolean) {
val free: Long get() = runCatching { StatFs(root.path).availableBytes }.getOrDefault(0L)
}
internal fun pickVolumes(context: Context): List<PickVolume> =
listOf(PickVolume(Environment.getExternalStorageDirectory(), context.getString(R.string.games_internal), removable = false)) +
GameStorage.options(context).mapNotNull { o ->
File(o.path.substringBefore("/Android/")).takeIf { it.isDirectory }?.let { PickVolume(it, o.name, removable = true) }
}.distinctBy { it.root.absolutePath }
/** A folder in a listing; [readable] false = shown greyed, not opened. */
internal class PickFolder(val dir: File, val readable: Boolean)
internal class PickListing(val folders: List<PickFolder>, val files: List<File>)
/**
* Every folder of [dir] (dot-folders left out, the unreadable ones marked), then the files [kind]
* takes; each A-Z.
*/
internal fun pickListing(dir: File, kind: PickKind): PickListing {
val all = dir.listFiles()?.filter { !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() }
return PickListing(
all.filter { it.isDirectory }.map { PickFolder(it, it.canRead() && it.list() != null) },
all.filter { it.isFile && it.extension.lowercase() in kind.extensions },
)
}
/**
* The compact in-app file picker: [title] and a red X on one line; the storage rows (internal
* storage, each card, free space); inside one, its root first, a breadcrumb, "..", every folder
* and the [kind] files, scrolling in the dialog. Its width is what the first step needed and stays;
* long names are cut short. B / Back goes up a folder, and closes from the storage rows. [start]
* opens in a folder. [onAddAll] adds a row on top of a folder with subfolders.
*/
@Composable
internal fun ExePickerDialog(
title: String, kind: PickKind = PickKind.EXE, start: File? = null,
onAddAll: ((File) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit,
) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val volumes = remember { pickVolumes(ctx) }
fun volumeOf(d: File) = volumes.filter { d.path == it.root.path || d.path.startsWith(it.root.path + "/") }.maxByOrNull { it.root.path.length }
var dir by remember { mutableStateOf(start?.takeIf { it.isDirectory && volumeOf(it) != null }) }
val volume = dir?.let { volumeOf(it) }
val up = { dir = dir?.let { d -> if (volume == null || d.path == volume.root.path) null else d.parentFile } }
val back = { if (dir == null) close() else up() }
val first = remember(dir) { FocusRequester() }
LaunchedEffect(dir) { focusWithinFrames({ false }) { first } }
val listing = remember(dir) { dir?.let { pickListing(it, kind) } }
var locked by remember { mutableStateOf<Dp?>(null) }
AppDialog(
shown, close, "filePicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back,
modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } },
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
DialogTitleRow(title, onClose = close)
if (dir != null && volume != null) {
val crumbs = listOf(volume.label) + dir!!.path.removePrefix(volume.root.path).split('/').filter { it.isNotEmpty() }
Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.7f).dp
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()).padding(top = 4.dp),
) {
if (dir == null) {
volumes.forEachIndexed { i, v ->
key(v.root.path) {
PickerRow(if (v.removable) Icons.Outlined.SdCard else Icons.Outlined.Smartphone, v.label,
stringResource(R.string.stores_target_free, formatBytes(v.free)), tall = true, focus = if (i == 0) first else null) { dir = v.root }
}
}
} else {
val shownListing = listing ?: PickListing(emptyList(), emptyList())
PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up)
if (onAddAll != null && shownListing.folders.any { it.readable }) {
PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!); close() }
}
for (f in shownListing.folders) key(f.dir.path) {
PickerRow(Icons.Outlined.Folder, f.dir.name, null, enabled = f.readable) { dir = f.dir }
}
for (f in shownListing.files) key(f.path) {
PickerRow(if (kind == PickKind.IMAGE) Icons.Outlined.Image else Icons.Outlined.InsertDriveFile, f.name, formatBytes(f.length()), file = true) {
onPick(f); close()
}
}
}
}
}
}
}
/** A compact dialog's title line: [leading] (a back arrow), the title, a small red X. */
@Composable
internal fun DialogTitleRow(title: String, onClose: () -> Unit, leading: (@Composable () -> Unit)? = null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
leading?.invoke()
Text(title, fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = MaterialTheme.colorScheme.error, onClick = onClose)
}
}
/** A 32 dp icon button for a dialog's title line. */
@Composable
internal fun SmallIconButton(icon: ImageVector, description: String, tint: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(32.dp).clip(Shape12)
.background(if (hot) tint.copy(alpha = 0.15f) else androidx.compose.ui.graphics.Color.Transparent)
.glideBorder(hot, Shape12, tint)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = description },
) {
Icon(icon, null, tint = tint, modifier = Modifier.size(18.dp))
}
}
/** One row of a compact dialog's list, the Install dialog's style: [content] laid out in a row. */
@Composable
internal fun ListRow(
enabled: Boolean = true, accent: Boolean = false, tall: Boolean = false, focus: FocusRequester? = null,
onClick: () -> Unit, content: @Composable androidx.compose.foundation.layout.RowScope.(hot: Boolean) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(Shape12)
.alpha(if (enabled) 1f else 0.4f)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f))
.glideBorder(hot, Shape12, pal.signal, if (accent) pal.signal else pal.line)
.then(
if (enabled) Modifier.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick) else Modifier,
)
.padding(horizontal = 12.dp, vertical = if (tall) 11.dp else 9.dp),
) { content(hot) }
}
/** A list row with an icon, a name (cut short when long) and a detail on the right. */
@Composable
internal fun PickerRow(
icon: ImageVector?, name: String, detail: String?, tall: Boolean = false, file: Boolean = false, accent: Boolean = false,
enabled: Boolean = true, focus: FocusRequester? = null, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
ListRow(enabled, accent, tall, focus, onClick) { hot ->
if (icon != null) Icon(icon, null, tint = if (hot || file || accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(name, fontSize = 14.sp, fontWeight = if (tall) FontWeight.SemiBold else FontWeight.Normal, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, modifier = Modifier.padding(start = 8.dp))
}
}
@@ -424,11 +424,18 @@ private fun Content(
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var confirmRemove by rememberSaveable { mutableStateOf(false) }
if (confirmRemove) ConfirmDialog(
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
)
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
BusyChip(s)
}
}
@@ -30,6 +30,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -55,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.frontend.Library
import kotlinx.coroutines.launch
// The Games page: the list, the hero for the selected game and its labels.
@@ -67,9 +72,15 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val narrow = LocalNarrowPane.current
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
GamesDialogs(a)
val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true })
val pageContext = androidx.compose.ui.platform.LocalContext.current
val onEdit: (Library.SteamGame) -> Unit = { g ->
g.gameFiles?.let { GamesDialogState.editing = EditRequest(it.path, openTarget = false, fromSummary = false, preview = EditPreview.of(pageContext, g, it.path)) }
}
if (current == null) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
Rise(0) { PageHeader(stringResource(R.string.content_games)) { onAdd?.let { AddGameButton(it) } } }
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
if (!s.shortcutPicker) Rise(2) {
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) }
@@ -81,11 +92,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val host = rememberMenuHost()
if (games.size == 1) {
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
if (onAdd != null) Rise(0) { GamesHeader(1, onAdd, Modifier.padding(bottom = 10.dp)) }
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
GameActions(current, s, a)
GameActions(current, s, a, onEdit)
}
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
@@ -98,7 +110,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
}
val pal = LocalPalette.current
Row(modifier = modifier) {
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) },
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) }, onAdd = onAdd,
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(
@@ -107,7 +119,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a)
GameActions(current, s, a, onEdit)
}
SectionTitle(stringResource(R.string.games_launch_settings), null)
LaunchSettings(s, a, host, current)
@@ -135,7 +147,7 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) {
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit) {
Actions {
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
@@ -147,6 +159,8 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
}
if (!s.shortcutPicker) GameShortcutMenu(g, a)
// A game added in DroidDeck (Custom): its editor, Steam's Properties for a non-Steam game.
if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) }
BusyChip(s)
}
}
@@ -299,7 +313,7 @@ private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.c
@Composable
private fun GameList(
games: List<Library.SteamGame>, current: Library.SteamGame,
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier,
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
// Laid out whole, as the art grid is: the pad's focus search only finds rows that exist.
@@ -307,16 +321,145 @@ private fun GameList(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
}
GamesHeader(games.size, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
// A game that arrives while the list is up (Steam installed it in the session that just
// ended, or it was added) eases in; the ones there when the page opened are simply there.
val seen = remember { HashSet<Int>() }
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
for (g in games) key(g.appId) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
Box(Modifier.arriveIn(settled && g.appId !in seen)) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
}
}
androidx.compose.runtime.SideEffect {
seen.addAll(games.map { it.appId })
if (games.isNotEmpty()) settled = true
}
}
}
/** "Games N", and the + that adds a game from its .exe beside it ([onAdd] null: no +). */
@Composable
private fun GamesHeader(count: Int, onAdd: (() -> Unit)?, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier) {
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(count.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant)
if (onAdd != null) AddGameButton(onAdd)
}
}
/**
* The Games tab's dialogs, one at a time: the + picker, the summary after "Add all games in this
* folder", and a game's editor (from the page's ✎ or a summary row, with back to the summary).
*/
internal object GamesDialogState {
var adding by androidx.compose.runtime.mutableStateOf(false)
var summary by androidx.compose.runtime.mutableStateOf<Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>?>(null)
var editing by androidx.compose.runtime.mutableStateOf<EditRequest?>(null)
/** The subfolder "Add all games in this folder" is looking at; null when it is done. */
var checking by androidx.compose.runtime.mutableStateOf<String?>(null)
/** The summary an editor opened from it goes back to. */
var behindEditor: Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>? = null
}
@Composable
private fun GamesDialogs(a: FrontEndActions) {
val ctx = androidx.compose.ui.platform.LocalContext.current
val scope = androidx.compose.runtime.rememberCoroutineScope()
val st = GamesDialogState
if (st.adding) ExePickerDialog(
stringResource(R.string.games_add_title),
onAddAll = { dir ->
st.summary = emptyList<com.droiddeck.launcher.frontend.AddedGames.Game>() to 0
st.checking = dir.name
scope.launch {
val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.droiddeck.launcher.frontend.AddedGames.addFolder(
ctx.applicationContext, dir,
onGame = { g -> scope.launch { st.summary?.let { (list, n) -> st.summary = (list + g) to n } } },
onChecking = { name -> scope.launch { st.checking = name } },
)
}
st.checking = null
a.onAddedGamesChanged(result.added.map { it.folder.path })
st.summary?.let { (list, _) -> st.summary = list to result.already }
}
},
onPick = { a.onAddGameExe(it.path) },
onDismiss = { st.adding = false },
)
st.summary?.let { (added, already) ->
AddedGamesSummaryDialog(added, already, checking = st.checking, onOpen = { folder, uncertain, preview ->
st.behindEditor = added to already
st.editing = EditRequest(folder, openTarget = uncertain, fromSummary = true, preview = preview)
}, onRemove = { folder, appId, name ->
st.summary = st.summary?.let { (list, n) -> list.filterNot { it.folder.path == folder } to n }
a.onAddedGameRemoved(folder, appId, name)
}, onDismiss = { st.summary = null; st.checking = null })
}
st.editing?.let { request ->
AddedGameEditor(
request,
onBack = { st.behindEditor?.let { st.summary = it }; st.behindEditor = null },
onClose = { st.editing = null },
onChanged = { a.onAddedGamesChanged(listOf(request.folder)) },
onRemove = { folder, appId, name -> a.onAddedGameRemoved(folder, appId, name) },
)
}
}
/** The ✎ on a Custom game's page: icon only. */
@Composable
private fun EditGameButton(onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val label = stringResource(R.string.common_edit)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("game:edit").size(40.dp).clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.55f))
.glideBorder(hot, Shape12, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = label },
) {
androidx.compose.material3.Icon(Icons.Outlined.Edit, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(18.dp))
}
}
/** A round + that opens the .exe picker. */
@Composable
private fun AddGameButton(onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val label = stringResource(R.string.games_add)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("games:add").size(32.dp).clip(CircleShape)
.background(if (focused || hovered) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)
.glideBorder(focused, CircleShape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = label },
) {
androidx.compose.material3.Icon(Icons.Filled.Add, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(20.dp))
}
}
/** Fades and slides a row in once when [arriving]; nothing for one already there (or with animations off). */
@Composable
internal fun Modifier.arriveIn(arriving: Boolean): Modifier {
val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) }
LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) }
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() }
}
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
@Composable
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
@@ -33,13 +33,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
@@ -86,6 +89,7 @@ class FrontEndState(
val stage: String,
val percent: Int,
val desktopInstalled: Boolean,
val desktopPresent: Boolean = desktopInstalled,
val offlineAccount: String?,
val offline: Boolean,
val frameGenLabel: String,
@@ -125,6 +129,7 @@ class FrontEndState(
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
val showPhantomGate: Boolean = false,
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val animationsEnabled: Boolean = true,
/** The Flathub Store, a beta the user turns on in Setup. */
@@ -137,6 +142,9 @@ class FrontEndState(
val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY,
/** A page to open once: "stores:downloads", or a selection such as "app:<id>" (a notification's tap). */
val navigate: String? = null,
/** Setup: art for added games is fetched; the user has a SteamGridDB key of their own. */
val addedGamesArt: Boolean = true,
val sgdbUserKey: Boolean = false,
val storesShowMature: Boolean = false,
/** Downloads queued, running or paused across the three stores; the rail item's badge. */
val storeDownloadsActive: Int = 0,
@@ -151,6 +159,15 @@ class FrontEndActions(
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onExportGameFile: (Library.SteamGame) -> Unit = {},
val onSyncGameFiles: () -> Unit = {},
/** The Games tab's +: the .exe picked in its dialog, to add as a game. */
val onAddGameExe: (path: String) -> Unit = {},
/** These added games (by folder) were added or edited: the Games tab updates just them. */
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
/** An added game removed in its editor: off the Games tab at once, out of Steam after. */
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
/** Setup's Artwork switch and the user's SteamGridDB API key ("" removes it). */
val onAddedGamesArt: (Boolean) -> Unit = {},
val onSgdbKey: (String) -> Unit = {},
val onStopGameFileSync: () -> Unit = {},
val onCopyGameLink: (Library.SteamGame) -> Unit = {},
val onDesktop: () -> Unit,
@@ -163,6 +180,7 @@ class FrontEndActions(
val onResume: () -> Unit,
val onSteamSettings: () -> Unit,
val onDesktopSettings: () -> Unit,
val onRemoveDesktop: () -> Unit = {},
val onInstallPackage: (String) -> Unit,
val onRemovePackage: (String) -> Unit,
val onRuntime: () -> Unit,
@@ -187,6 +205,7 @@ class FrontEndActions(
val onTheme: (String) -> Unit = {},
val onLanguage: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onOrientation: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
@@ -454,7 +473,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
var anyFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize().background(colors.background)
.then(if (s.launcherFullscreen) Modifier else Modifier.systemBarsPadding())
.windowInsetsPadding(if (s.launcherFullscreen) WindowInsets.displayCutout else WindowInsets.systemBars)
.onFocusChanged { anyFocused = it.hasFocus },
) {
// Start controllers on the current page's main action, else on the rail.
@@ -164,6 +164,8 @@ internal fun SetupPanel(
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
var editSgdbKey by remember { mutableStateOf(false) }
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
@@ -349,6 +351,11 @@ internal fun SetupPanel(
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ChoiceRow(
host, "orientation", stringResource(R.string.setup_orientation), null,
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.orientation, onPick = a.onOrientation,
)
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
@@ -368,6 +375,13 @@ internal fun SetupPanel(
s.storeEnabled,
) { a.onStoreEnabled(it) }
}
SettingsGroup(stringResource(R.string.content_games)) {
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
ActionRow(
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
)
}
SettingsGroup(stringResource(R.string.setup_stores)) {
ToggleRow(
host, "stores-enabled", stringResource(R.string.setup_stores_show),
@@ -496,6 +510,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
game = game,
) { wincompOpen = null }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
@@ -0,0 +1,116 @@
package com.droiddeck.launcher.ui
import kotlin.math.min
/**
* Where the unfolded screen's gamepad puts each control, for a panel of a given size: plain
* numbers, so the layout is tested without a device (GamepadGeometryTest).
*
* Each thumb pivots at its bottom corner; its main control (a stick or trackpad) sits where it
* rests, diagonally in from the corner, and the right side mirrors the left.
*
* A wide strip (a foldable held flat as a tablet): the second control - the d-pad, the face
* buttons - a slide inward along the arc, the shoulders above, and View, Steam, QAM and Menu
* between the arcs. The reach R is the strip's height, or a third of its width.
*
* A tall panel (held upright): the second control a slide up the arc instead, the shoulders above
* that, View, Steam, QAM and Menu between the two sticks, and Panels - the Deck controls' other
* tabs - between the shoulders. Each side's column S is half the width, or as much as the height
* holds. A wide strip has no room for Panels and leaves it out.
*/
object GamepadGeometry {
enum class Slot {
LEFT_MAIN, LEFT_SECOND, RIGHT_MAIN, RIGHT_SECOND,
LEFT_OUTER_SHOULDER, LEFT_INNER_SHOULDER, RIGHT_INNER_SHOULDER, RIGHT_OUTER_SHOULDER,
VIEW, STEAM, QAM, MENU,
/** The Deck controls' other tabs: on a tall panel only. */
PANELS,
}
/** A control's box: its centre and size, in pixels. */
data class Box(val cx: Float, val cy: Float, val width: Float, val height: Float) {
val left get() = cx - width / 2f
val top get() = cy - height / 2f
val right get() = cx + width / 2f
val bottom get() = cy + height / 2f
fun overlaps(other: Box): Boolean =
left < other.right && other.left < right && top < other.bottom && other.top < bottom
}
/** The middle buttons, in a row in this order; two by two as on a Deck, View and Menu above Steam and QAM. */
private val middle = listOf(Slot.VIEW, Slot.STEAM, Slot.QAM, Slot.MENU)
private val twoByTwo = mapOf(Slot.VIEW to 0, Slot.MENU to 1, Slot.STEAM to 2, Slot.QAM to 3)
/** A panel this shape is laid out as a tall panel (held upright), which has room for Panels;
* anything flatter is a wide strip. */
fun tall(w: Float, h: Float): Boolean = h >= w * 0.45f
fun layout(w: Float, h: Float, density: Float): Map<Slot, Box> {
if (w <= 0f || h <= 0f) return emptyMap()
val pad = 8 * density
val buttonH = 40 * density
// A row needs its buttons comfortable; two by two will take them a little narrower.
val rowButton = 64 * density
val gridButton = 48 * density
val gutter = 8 * density
val boxes = LinkedHashMap<Slot, Box>()
fun mirror(x: Float, right: Boolean) = if (right) w - x else x
fun pair(left: Slot, right: Slot, x: Float, cy: Float, bw: Float, bh: Float) {
boxes[left] = Box(mirror(x, false), cy, bw, bh)
boxes[right] = Box(mirror(x, true), cy, bw, bh)
}
fun middleGrid(columns: Int, span: Float, top: Float) {
val each = (span / columns).coerceAtMost(110 * density)
for ((order, slot) in middle.withIndex()) {
val index = if (columns == 2) twoByTwo.getValue(slot) else order
val x = w / 2f + (index % columns - (columns - 1) / 2f) * each
boxes[slot] = Box(x, top + buttonH / 2f + (index / columns) * (buttonH + 10 * density), each - gutter, buttonH)
}
}
if (!tall(w, h)) {
val reach = min(h - pad, w * 0.33f)
val main = reach * 0.66f
val second = reach * 0.54f
val mainTop = h - reach * 0.40f - main / 2f
val shoulderW = reach * 0.36f
val shoulderH = (reach * 0.16f).coerceAtLeast(buttonH)
// Above the main controls, never over them, and inside the panel.
val shoulderY = min(h - reach * 0.94f + shoulderH / 2f, mainTop - gutter - shoulderH / 2f)
.coerceAtLeast(pad + shoulderH / 2f)
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, reach * 0.40f, h - reach * 0.40f, main, main)
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, reach * 1.03f, h - reach * 0.30f, second, second)
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, reach * 0.22f, shoulderY, shoulderW, shoulderH)
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, reach * 0.62f, shoulderY, shoulderW, shoulderH)
// Between the arcs: in a row if they fit, else two by two, else one above another.
val gap = w - 2 * reach * 1.32f
val columns = when {
gap >= middle.size * rowButton -> middle.size
gap >= 2 * gridButton -> 2
else -> 1
}
middleGrid(columns, gap - 2 * gutter, shoulderY - buttonH / 2f)
} else {
val side = min(w * 0.5f, h * 0.70f)
val main = side * 0.62f
val second = side * 0.46f
val secondCy = h - side * 0.97f
val shoulderW = side * 0.34f
val shoulderH = (side * 0.13f).coerceAtLeast(buttonH)
val shoulderY = (secondCy - second / 2f - gutter - shoulderH / 2f).coerceAtLeast(pad + shoulderH / 2f)
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, side * 0.40f, h - side * 0.40f, main, main)
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, side * 0.74f, secondCy, second, second)
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, side * 0.20f, shoulderY, shoulderW, shoulderH)
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, side * 0.57f, shoulderY, shoulderW, shoulderH)
// Between the two sticks at the bottom: two by two, or one above another.
val gap = w - 2 * (side * 0.40f + main / 2f)
val columns = if (gap >= 2 * gridButton) 2 else 1
val rows = (middle.size + columns - 1) / columns
middleGrid(columns, gap - 2 * gutter, h - pad - rows * buttonH - (rows - 1) * 10 * density)
// Between the inner shoulders, where it fits.
val panelsW = min(112 * density, w - 2 * (side * 0.57f + shoulderW / 2f) - 2 * gutter)
if (panelsW >= buttonH) boxes[Slot.PANELS] = Box(w / 2f, shoulderY, panelsW, buttonH)
}
return boxes
}
}
@@ -86,15 +86,13 @@ class ModeSettings(
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
val syncBackend: String? = null,
val stretch16x9: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
val steamRepairQueued: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
/** Steam: the QAM's performance overlay (mangoapp) is started. */
val mangoapp: Boolean = true,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
val steamController: String? = null,
/** Unfolded controls (Steam only; null hides the row). */
val unfoldedControls: Boolean? = null,
/** Steam only: start a Steam session when DroidDeck opens. */
val runSteamAtStartup: Boolean = false,
/** Null outside Steam; the saved choice is separate from Android's access and device switch. */
@@ -103,10 +101,6 @@ class ModeSettings(
val wifiDiscoveryLocation: Boolean = false,
val wifiDiscoveryAsked: Boolean = false,
val wifiDiscoveryBlocked: Boolean = false,
/** Steam only: the user's chosen Games folders; null outside Steam. */
val addedGamesDirs: List<String>? = null,
val addedGames: List<AddedGameRow> = emptyList(),
val addedGamesArt: Boolean = true,
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
/** Steam only: Decky Loader is managed from the Steam session settings. */
val deckyInstalled: String? = null,
@@ -120,9 +114,6 @@ class ModeSettings(
val deckySessionRunning: Boolean = false,
)
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
class ModeSettingsActions(
val onResolution: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
@@ -146,19 +137,13 @@ class ModeSettingsActions(
val onForceSsbs: (Boolean) -> Unit = {},
val onSyncBackend: (String) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onUnfoldedControls: (Boolean) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onWifiDiscovery: (Boolean) -> Unit = {},
val onWifiDiscoverySettings: () -> Unit = {},
val onPickAddedGamesDir: () -> Unit = {},
val onForgetAddedGamesDir: (path: String) -> Unit = {},
val onAddedGamesArt: (Boolean) -> Unit = {},
val onAddedGameExe: (folderPath: String, path: String) -> Unit = { _, _ -> },
val onPickAddedGameExe: (folderPath: String) -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
val onDeckyCheck: () -> Unit = {},
val onDeckyEnabled: (Boolean) -> Unit = {},
@@ -268,6 +253,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
@@ -277,11 +263,19 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
@@ -384,25 +378,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
if (s.steamDeckMode) ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
else ChoiceRow(
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
@@ -429,33 +410,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
}
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) stringResource(R.string.mode_added_none) else pluralStringResource(R.plurals.mode_added_count, n, n)) + ". " + stringResource(R.string.added_games_forget_hint),
stringResource(R.string.common_forget), onClick = { a.onForgetAddedGamesDir(dir) },
)
}
ActionRow(
if (s.addedGamesDirs.isEmpty()) stringResource(R.string.mode_games_folder) else stringResource(R.string.mode_games_folder_another),
stringResource(R.string.added_games_import_hint),
stringResource(R.string.common_add_ellipsis), onClick = a.onPickAddedGamesDir,
)
ToggleRow(
host, "addedArt", stringResource(R.string.mode_added_art),
stringResource(R.string.mode_added_art_hint),
s.addedGamesArt, onChange = a.onAddedGamesArt,
)
for (g in s.addedGames) {
ChoiceRow(
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
note = stringResource(R.string.mode_added_exe_note),
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
}
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
@@ -552,6 +506,31 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
/** Width × height for the session, with the common handheld shapes one tap away. */
/** Entry for the user's SteamGridDB API key; the saved key is never shown back. */
@Composable
internal fun SgdbKeyDialog(hasKey: Boolean, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var key by remember { mutableStateOf("") }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.mode_sgdb_key)) },
text = {
androidx.compose.material3.OutlinedTextField(
key, { v -> key = v.trim() }, label = { Text(stringResource(R.string.mode_sgdb_key)) },
singleLine = true, visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = { androidx.compose.material3.TextButton(onClick = { onSave(key) }, enabled = key.isNotEmpty()) { Text(stringResource(R.string.common_ok)) } },
dismissButton = {
androidx.compose.foundation.layout.Row {
if (hasKey) androidx.compose.material3.TextButton(onClick = { onSave("") }) { Text(stringResource(R.string.common_delete)) }
androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
}
},
)
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
@@ -194,6 +194,9 @@ class DrawerActions(
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val backActionsInverted: Boolean,
/** What the pad is to Steam (SessionPrefs.CONTROLLER_*), switched live; null hides the row. */
val padType: String? = null,
val onPadType: (String) -> Unit = {},
val touchMode: String,
val touchAuto: String,
val fexPreset: String,
@@ -217,6 +220,8 @@ class DrawerActions(
val onSteamMenu: (() -> Unit)?,
val onQam: (() -> Unit)?,
val onOsc: (String) -> Unit,
/** Edits the Steam touch layout of the game in front, when Steam's controls are up. */
val onEditSteamTouch: (() -> Unit)? = null,
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
val onRumble: (Boolean) -> Unit = {},
val onSteamButton: (Boolean) -> Unit = {},
@@ -240,6 +245,9 @@ class DrawerActions(
val pipAutoEnter: Boolean = false,
val onPip: () -> Unit = {},
val onPipAutoEnter: (Boolean) -> Unit = {},
/** Unfolded controls (on a foldable; null hides the row). */
val unfoldedControls: Boolean? = null,
val onUnfoldedControls: (Boolean) -> Unit = {},
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
@@ -414,13 +422,27 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
a.padType?.let { type ->
ChoiceRow(host, "pad-type", stringResource(R.string.mode_controller), null,
listOf(SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360)),
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
}
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
// Steam's touch controller is Steam's alone; elsewhere that choice behaves as Auto.
if (!a.steam && a.oscMode == SessionPrefs.OSC_STEAM_TOUCH) SessionPrefs.OSC_AUTO else a.oscMode,
chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
a.onEditSteamTouch?.let { edit ->
ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit)
}
a.controller?.let { c ->
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
@@ -438,6 +460,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
}
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more