Compare commits

..
Author SHA1 Message Date
Kurt Himebauch bb4a6d0480 fix(preload): treat any nonempty ELECTRON_RUN_AS_NODE as Electron Node mode 2026-11-10 18:18:27 +00:00
Kurt Himebauch 0e9a43476f Fix Electron Node mode sandbox injection 2026-11-10 10:59:52 -05:00
Kurt Himebauch 98ab71a814 docs: point README downloads at 0.7.1 (#2401) 2026-11-10 07:38:07 -05:00
Kurt Himebauch cdff94e72c chore: bump version to 0.7.1 (19) (#2400) 2026-11-10 07:21:27 -05:00
Kurt Himebauch aa1984bb94 docs: DroidDeck 0.7.1 release notes (#2399)
* docs: DroidDeck 0.7.1 release notes

* docs: add #2392 to 0.7.1 notes
2026-11-10 07:21:03 -05:00
MaxsTechReview 634cb390c9 fix(proton-run): register the wrapper for an installed Valve Proton depot (#2392)
* fix(proton-run): find Proton tools in Steam libraries

* fix(proton-run): register the wrapper for an installed Valve Proton depot

Valve's ARM64 Proton depots are not compatibility tools, and running
them directly bypasses the DroidDeck wrapper. Register the wrapper from
the depot when a store app is launched before Steam has started, instead
of scanning steamapps/common.

* test(proton): install complete tool fixtures in the auto-update tests

* fix(proton-run): skip incomplete Proton depots and keep listing tools when the wrapper cannot be written

* fix(proton-run): pick a runnable depot and rebuild a wrapper whose launcher is missing

* test(proton-run): remove redundant depot fixture write

* fix(proton-run): rebuild a wrapper whose manifest is missing

* test(proton-run): cover runnable depot selection
2026-11-10 07:09:56 -05:00
MaxsTechReviewandKurt Himebauch 13c8edb3d5 fix(game-env): repair foreign-architecture system DLLs in ARM64 prefixes (#2318)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-11-10 06:24:03 -05:00
MaxsTechReview 1ec2b696b7 fix(flatpak): stop inheriting host Zink overrides (#2391)
* fix(flatpak): stop inheriting host zink overrides

* Check every cleared driver override in the Flatpak launcher test
2026-11-10 02:55:13 -05:00
MaxsTechReview 621fd8d43e fix(gog): install owned DLC (#2348) 2026-11-09 23:58:24 -05:00
LunaDL 17936530aa feat(display): add left and right picture alignment (#2384) 2026-11-09 23:41:25 -05:00
LunaDL ae3e9509f4 Feat: Add new theme color for launcher: Scarlet snow (#2262) 2026-11-09 20:29:14 -05:00
MaxsTechReview 6bcd431ca6 fix(session): wait for Steam readiness before launch (#2362)
* fix(session): wait for Steam readiness before launch

* fix(session): keep the launch fallback when Steam readiness or the interstitial is unavailable

* fix(session): cap the Steam readiness wait by elapsed time and bound each check

* fix(session): keep waiting for Steam readiness through focus changes and never pass a zero timeout
2026-11-09 18:23:24 -05:00
MaxsTechReview 836340f3a2 fix(session): no endless loading screen after a game exits back to Steam (#2359)
* fix(session): avoid stale relaunch loading overlay

* fix(session): keep the loading screen while the runtime installs
2026-11-09 17:09:27 -05:00
Kurt Himebauch 1b19837fc6 fix(native): a guest's own seccomp filter gets its SIGSYS, so Firefox tabs stop crashing (#2360) 2026-11-09 16:42:38 -05:00
Kurt Himebauch c85b84a964 fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup (#2361)
* fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup

* fix(ctl): screenshot finds display ids that the device name carries inline

* fix(input): fakeinput registers its fork handlers before other threads see its config
2026-11-09 16:41:26 -05:00
MaxsTechReview 75a0230304 fix(input): Auto relative mouse stays off for direct touch without a mouse (#2305)
* fix(input): Auto relative mouse stays off for direct touch without a mouse

* fix(input): re-check relative mouse when an input device changes

* fix(input): reapply relative mouse after agent readiness

* fix(input): only the current session clears the agent-ready hook and readiness resets relative mouse retries
2026-11-09 15:02:12 -05:00
Kurt Himebauch 7c89c14a84 fix(dual-screen): drop the Dashboard notifications page and its notification listener (#2342)
* fix(dual-screen): drop the Dashboard notifications page and its notification listener

* test(session): freeze the clock in the memory-pressure policy harness
2026-11-09 12:48:00 -05:00
Kurt Himebauch b837611efe fix(ctl): a full disk no longer crashes the app when an agent call cannot write its answer (#2325) 2026-11-09 12:33:26 -05:00
Kurt Himebauch bf434b2652 fix(hud): steady frame graph on the bottom screen dashboard (#2345) 2026-11-09 12:30:41 -05:00
GiovanniCarbinatti 35941bb9e7 chore: update README.md (#2311) 2026-11-09 06:45:24 -05:00
MaxsTechReview 96a12465e0 fix(session): stop re-compressing game memory every 30 s when the game reads it straight back (#2312)
* fix(session): stop re-compressing game memory every 30 s when the game reads it straight back

On devices with 13 GB or less, the low-memory page-out skipped its
read-back backoff whenever memory was nearly exhausted. When the game
was actively using what was compressed, it read it straight back, so
the session pushed and re-read gigabytes every 30 seconds, pinning all
cores and freezing the game for 5-10 s each time. The read-back check
now applies on that path too: an effective page-out still repeats every
30 s, an ineffective one backs off as on the normal path.

* zRAM: keep the read-back backoff on the nearly-out-of-memory path

* zRAM: retry a skipped page-out after 30 s when nearly out of memory
2026-11-09 02:08:40 -05:00
Kurt Himebauch 88d6b70881 fix(ctl): reach the agent bridge by broadcast when the shell has no content command (#2321) 2026-11-09 00:00:20 -05:00
MaxsTechReview 29823bb1bf fix(steam): Linux-only tools keep their native platform (#2313)
* fix(steam): reconcile tool dependencies by supported platform

* fix(steam): refresh tool metadata and clear stale GE mappings

* fix(steam): clear any Proton mapping from Linux-only tools

* fix(steam): watch folders the client creates after the helper starts

* Watch each missing directory level as it appears

* Make the watcher level test wait on inotify readiness
2026-11-08 22:22:22 -05:00
MaxsTechReview 92a7124589 fix(gpu): skip system Vulkan pruning when the runtime pack is absent (#2316)
fix: skip pruning system Vulkan runtimes when the runtime pack is not installed
2026-11-08 22:07:39 -05:00
Kurt Himebauch bf95a40e3e fix(ci): post new Discord notice for each PR build (#2314)
Post a fresh signed test-build notice for each new PR commit, retire the
previous notice, and include the latest commit subject.
2026-11-08 21:19:22 -05:00
Kurt Himebauch 1355cb3a2b fix(ci): handle empty commit lookup responses 2026-11-08 21:13:53 -05:00
Kurt Himebauch 51407155fc fix(ci): keep commit lookup failures from blocking notices 2026-11-08 21:12:58 -05:00
Kurt Himebauch b4a953c659 fix(ci): post new Discord notice for each PR build 2026-11-08 21:08:22 -05:00
MaxsTechReview 5e438e9248 fix(gog): owned games the listing leaves out stay owned, and search reaches GOG's catalog (#2296)
* fix(gog): owned games the library listing leaves out stay owned

Since the GOG library is read from account/getFilteredProducts, a game
that listing does not return (one hidden in GOG Galaxy, for example)
dropped out of the library, and with it out of the GOG tab, which shows
owned games and the shelves. 0.5.0 read every owned id from
user/data/games and showed them.

The sync now takes the union of the listing and user/data/games. The
listing still names most games at once, and an owned id it lacks is
fetched like any other.

* fix(gog): the GOG tab's search reaches GOG's catalog

GogStoreCatalog.search was never wired to the backend, so searching the
GOG tab only filtered the owned library and the featured shelves; a game
that is neither could not be found. GogBackend now answers search from
GOG's catalog, like the other stores.

* fix(gog): keep cached owned games when the account list is unavailable
2026-11-08 19:37:20 -05:00
MaxsTechReview 6b110581ac fix(gog): install scripts' support files reach the prefix (#2294)
* fix(gog): install scripts' support files reach the prefix

A goggame-<id>.script can copy files into the prefix with supportData
actions: default INI files, Plugins.txt and other settings that mods such
as one-click editions need. Only setRegistry actions were applied, so
those games started without their configuration and ran as the base game.

droiddeck-store-launch now applies supportData after the registry, once
per script revision: copies from {app} and {supportDir} into {userdocs}
and {localappdata}, matching paths without regard to case. Links are
copied as links, paths outside those folders are refused, and existing
files are replaced only when the action asks for it.

* Keep GOG support data copies inside the game and prefix folders

* Copy linked GOG support sources as links and keep a player's own links
2026-11-08 18:45:53 -05:00
MaxsTechReview 92742f37c1 fix(preload): answer netlink requests sent with write and writev (#2304) 2026-11-08 18:40:57 -05:00
MaxsTechReview ecd916d43f fix(input): show the panel tabs under the controls-below-the-game bar (#2302)
* fix(input): show the panel tabs under the controls-below-the-game bar

* fix(input): take the tab row out of the game, not the controls below it
2026-11-08 18:32:40 -05:00
Kurt Himebauch a7c10e3f51 fix(session): give Steam a session bus so launches stop hanging at the install script (#2303) 2026-11-08 16:53:01 -05:00
MaxsTechReview 1760f008ae fix(desktop): Flatpak and Snap apps get the game GPU setting (#2295)
The session's TU_DEBUG=sysmem is meant for the Steam client and
Chromium-based apps. Flatpak and Snap apps inherited it, so emulators
and other programs installed that way rendered through system memory.

droiddeck-game-env now holds the Chromium check next to game_gpu, and a
small droiddeck-game-gpu wrapper applies both to any program. The
AppImage, Flatpak and Snap launchers use it, so the rule lives in one
place.
2026-11-08 14:39:31 -05:00
MaxsTechReview 3bb279f635 fix(appimage): emulators and other AppImage programs get the game GPU setting (#2288)
The session sets TU_DEBUG=sysmem for the Steam client's interface and hands
games their own value in DROIDDECK_GAME_TU_DEBUG, which droiddeck-game-env
applies to Steam launches (#1188). droiddeck-appimage-run never did, so
emulators and other AppImage programs rendered every pass through system
memory instead of Turnip's per render pass choice.

Apply the game value in droiddeck-appimage-run the same way: an empty
value unsets TU_DEBUG, any other value replaces it. Chromium-based apps,
like the Steam client's own interface, keep the session's setting.
2026-11-08 12:23:30 -05:00
MaxsTechReview 3fef1f3fa1 fix(proton): a Proton picked for a game in Steam is kept (#2284)
fix(proton): a Valve Proton picked for a game in Steam stays on the ARM64 build made from it

Steam lists Valve's own Proton Experimental and Proton 11.0 next to the
DroidDeck tools. They are not runnable here, so a per-game pick of one was
replaced with the default. That used to be Proton Experimental ARM64, so the
pick looked kept; since an installed DroidDeck-Proton became the default, the
game is moved to DroidDeck-Proton instead.

When Steam records one of those tools for a title the sync already knows
(a title it mapped or one with a recorded pick), the title now gets the
DroidDeck tool built from the same Valve depot, stops following the default
and the choice is mirrored into the app's per-game setting. The same applies
to Steam's default. Titles nobody picked anything for still follow the
default, and Valve tools with no ARM64 build still fall back to it.
2026-11-08 12:03:21 -05:00
MaxsTechReview a4f26799ab fix(mesa): Zink on PowerVR shows colour again instead of grayscale (#2287)
Mesa 26.0.8, which the Ubuntu runtime builds, carries the backport of
"zink: create views for samplers lazily", which dropped the depth/stencil
aspect check in init_sampler_view(). On drivers with
needs_zs_shader_swizzle (Imagination) every colour view with a constant-1
alpha, such as RGBX/BGRX textures, then got the red-only depth swizzle
view, so Steam's interface and games showed red as R=G=B.

Carry Icenowy Zheng's upstream fix d55418dae7 (Mesa MR 41923), which
restores the aspect check. It applies on top of patch 0004.
2026-11-08 11:48:11 -05:00
MaxsTechReview 08a6a3cba8 ci: run approved fork PR compile jobs on the disposable fork runner (#2246)
* ci: run approved fork PR compile jobs on the disposable fork runner

* ci: ignore fork runner heartbeats from the future

* ci: target the fork runner by its own label

* Read the fork runner heartbeat from a public gist

* Fall back to an anonymous read of the fork runner heartbeat
2026-11-08 11:40:12 -05:00
MaxsTechReview 005fec2125 chore: pin gamescope to the DroidDeck-Components p23 release (#2285) 2026-11-08 11:37:54 -05:00
MaxsTechReview 2e70b8ed73 fix(gamescope): taps reach dialogs and windows away from the screen origin (#2280) 2026-11-08 10:44:00 -05:00
MaxsTechReview 74c0f2127a fix(proot): lay out ARM64 programs like the kernel so large reservations fit (#2279) 2026-11-08 10:43:57 -05:00
Kurt Himebauch 2bd7df5e1a chore: bump version to 0.7.0 (18) (#2278) 2026-11-08 09:36:10 -05:00
Kurt Himebauchandmaxjivi05 b7b96875a1 docs: DroidDeck 0.7.0 release notes (#2170)
* docs: DroidDeck 0.7.0 release notes

* docs: add 0.7.0 release note media

* docs: drop the 30 second summary from the 0.7.0 notes

* docs: add the dual-screen Dashboard shots to the 0.7.0 notes

* docs: show the Dashboard as one stacked dual-screen image

* docs: add the swap screens GIF to the 0.7.0 notes

* docs: swap in the new Syncthing screenshot

* docs: add the HUD dock GIF to the 0.7.0 notes

* docs: add the Game Jolt screenshot to the 0.7.0 notes

* docs: show the full screen in the HUD dock GIF

* docs: merge the GPU bullets, add the no proprietary components line, put images above their bullets

* docs: add the 0.7.0 APK download button to the release notes

* docs: add the first-time contributors to the 0.7.0 notes

* docs: add KeegantheVeganat0r as a first-time contributor

* docs: add the shared Steam libraries screenshot to the 0.7.0 notes

* docs: rewrite the shared Steam libraries bullet and move it up

* docs: add the Mali support photo to the 0.7.0 notes

* docs: center 0.7.0 release media

* docs: credit The412Banner for Mali support in 0.7.0 notes

* docs: add the KDE desktop GIF to the 0.7.0 notes

* docs: add the desktop and swapped game clips to the 0.7.0 notes

* docs: use the Duo photo for the swapped screens

* docs: replace the swap screens GIF with the Duo recording

* docs: drop the swap screens GIF

* docs: keep a blank line before each release image

* docs: tweak 0.7.0 release notes

* docs: move smaller 0.7.0 items to improvements

* docs: fold the bottom-screen HUD into the Strip HUD bullet

* docs: pair the swap screens and KDE desktop media

* docs: move Game Jolt and Syncthing up in 0.7.0 features

* docs: put the swap screens and KDE desktop cluster after Mali in 0.7.0 features

* docs: reword shared Steam libraries and reorder KDE desktop bullet

* docs: add recent merged PRs to the 0.7.0 notes

* docs: auto component updates follow DroidDeck-Proton only

* docs: add input profiles, low-RAM memory, driver Auto and other recent changes to the 0.7.0 notes

* docs: add the second-screen layout editor to the 0.7.0 controller profiles notes

* docs: refresh the 0.7.0 pull request and contributor counts, mention controller profiles in the summary

* docs: 0.7.0 notes add the latest fixes and the DroidDeck-Proton, DD-Turnip and component changes

Adds the user-visible pull requests merged after the last refresh (Library
sort, runtime pack update, desktop app launches, GitHub AppImage variant,
DroidDeck-Proton default, component auto-update cleanup, UBWC and FEX preset
settings, controller navigation) and a section for what shipped in
DroidDeck-Proton 11-11.10 to 11-12.3, DD-Turnip 0.4.0 and DroidDeck-Components
since 0.6.9.

---------

Co-authored-by: maxjivi05 <[email protected]>
2026-11-08 09:32:48 -05:00
MaxsTechReview 51e5e3a2a4 ci: a finished build reports on pull requests from forks (#2277)
When a Build APK run finishes, Build validation finds its pull request with
commits/{sha}/pulls, which GitHub answers with an empty list for a commit that
only exists on a fork. The validator then took the build as superseded, so the
"Build APK" status of every fork pull request stayed at "awaiting APK
compilation" after a successful build. It now falls back to the open pull
request for the run's head owner and branch, still requiring the head commit
to match.
2026-11-08 09:07:36 -05:00
MaxsTechReview c755d05473 ci: cache the pinned Turnip archive and retry its download (#2276)
* ci: cache the pinned Turnip archive and retry its download

"Prepare graphics driver variants" fetched the same checksum-pinned Banners-Turnip
zip from GitHub on every run with a single 120 s attempt, so a 502 or a read
timeout failed whole PR builds three times today. The archive is now cached
between runs (it is still checked against its SHA-256 after a restore), and a
cold download is retried on timeouts, 429 and 5xx with a growing pause.

* ci: keep PowerVR's verified downloads between runs

tools/powervr/fetch.py already keeps checksum-verified downloads in
~/.cache/droiddeck-build, but CI never saved that folder, so every run fetched
the PowerVR components again and a read timeout failed "Build the add-on
packs". The folder joins the existing pack-inputs cache, keyed on
release.json too.
2026-11-08 09:07:31 -05:00
MaxsTechReview aa883830ca fix(session): give the runtime a machine ID so D-Bus programs such as melonDS start (#2275)
* fix(session): give the runtime a machine ID so D-Bus programs start

The Ubuntu runtime ships an empty /etc/machine-id for systemd to fill on first
boot, and nothing fills it in a DroidDeck session. libdbus aborts when it needs
the ID ("D-Bus library appears to be incorrectly set up"), so melonDS closed at
once from the desktop and GTK Flatpaks started outside the session bus failed.

The session now writes a fresh ID once when the file is empty or malformed and
never changes a valid one.

* fix(session): check the whole machine ID file and reject the all-zero ID
2026-11-08 08:54:56 -05:00
MaxsTechReview 45e39b7aaf fix(desktop): catalog AppImages such as RPCS3 start from the desktop menu again (#2274)
fix(desktop): catalog AppImages start from the desktop menu through the shipped runtime

RPCS3 from the desktop menu exited at once: its own AppImage runtime runs
dwarfsextract from a memfd, which PRoot cannot execute (EACCES), and it has no
fallback. Emulator rail launches already avoid this by starting arm64 images
through the shipped uruntime (#1191); the menu entries the catalog writes did not.

The rule now lives in one launcher, droiddeck-appimage-exec, used by the session
and by the catalog's menu entries. Entries written by older versions are moved
to it with the existing menu migration.
2026-11-08 08:39:23 -05:00
MaxsTechReview bc99cacd02 fix(desktop): a GitHub link to one AppImage installs and updates that variant (#2273)
* fix(desktop): a GitHub link to one AppImage installs and updates that variant

* fix(desktop): latest-release links, bad escapes and 32/64-bit variants

A /releases/latest/download/<file>.AppImage link now names its asset too, a
malformed %-escape in the field no longer throws while the dialog composes,
and x86-32 and x86_64 assets no longer collapse to the same variant once
version numbers are dropped.
2026-11-08 08:07:28 -05:00
MaxsTechReview 2beeb715fd fix(runtime): an update that brings a new PRoot pack no longer asks to install the runtime again (#2270)
* fix(runtime): an update that brings a new PRoot pack no longer asks to install the runtime again

* A pack-only install recovers an interrupted swap first and falls back to the full install
2026-11-08 06:27:33 -05:00
MaxsTechReview 39469e6e8f fix(proton): an installed DroidDeck-Proton replaces a Proton Experimental default nobody picked, and added shortcuts follow it (#2266)
* fix(proton): an installed DroidDeck-Proton replaces a Proton Experimental default nobody picked, and added shortcuts follow the default

* test: an owned shortcut left on the previous default follows a default change
2026-11-08 06:07:53 -05:00
MaxsTechReview 15baa296bb fix(storage): a Second library folder that is there is used even when its volume state isn't mounted (#2258)
fix(storage): a saved game folder that is there stays the library
2026-11-08 00:32:04 -05:00
MaxsTechReview e4a4775be5 fix(input): taps outside the PC keyboard reach the game while it is open (#2257)
* fix(input): taps outside the PC keyboard reach the game while it is open

* fix(input): the game's cursor stays visible while only the PC keyboard is open

* fix(input): a touch cancelled mid-stream stays closed until the finger lifts

* A resize also ends a pending two-finger touch

* A new touch clears a dismissal left by a stream that ended elsewhere

* A new touch is not held back by a three-finger tap whose end was consumed
2026-11-07 23:24:53 -05:00
MaxsTechReview 77ef10d601 fix(library): added and Battle.net games sort by when they were played and installed (#2256)
* fix(library): added and Battle.net games sort by when they were played and installed

Recently played and Recently installed put every added game, Battle.net
games included, at the bottom: nothing recorded a launch or an install time
for them, and the Steam play-time lookup was never reached once an entry had
a game. Launching an added game now records its play time, added games carry
their folder's install time (kept in the library cache), Battle.net installs
report theirs, and a zero time falls through to the next source.

* fix(library): an added game's play time is recorded only after it launches and kept when it is rebuilt

* fix(library): an added game's play time is recorded only when its launch is accepted

* fix(library): an added game's play time is recorded when its session actually starts

* fix(library): an added game's play time is recorded when its session service starts
2026-11-07 23:17:43 -05:00
MaxsTechReview 914b3588e1 fix(drivers): leaving a driver bundle no longer keeps its runtime half, and the Auto display picks the latest pair (#2255)
* fix(drivers): leaving a driver bundle's display half also drops its runtime half

Picking either half of an Android + Linux bundle selects both, but picking
another display driver afterwards changed only the display. The session
kept loading the bundle's Linux driver, so switching back to DroidDeck's
drivers could still leave the session failing to start. The runtime driver
now goes back to the default when the display leaves the active bundle; a
runtime driver chosen on its own is kept.

* fix(drivers): picking the Auto display hands both halves to Auto's latest DD-Turnip pair

* fix(drivers): the Auto display pick puts the display on Auto even when no pair fits the GPU
2026-11-07 21:47:04 -05:00
MaxsTechReview 2ade269be3 fix(gamescope): direct-touch drags work again in games that confine a hidden cursor (#2220)
* gamescope: a confine is relative only while it holds the X pointer

Patch 0128 made every hidden-cursor confine relative. Games that hide the
system cursor, draw their own and confine the pointer to their window then
took the host lock, and a direct touch drag became camera motion with no
button held, so cards and items could not be dragged.

A confine now goes relative once the X pointer stays away from the seat
position while input moves, as when a game recentres it or clips it to a
small rectangle, and returns to absolute when the pointer follows again or
the confine ends. Locks and the warp follow rule are unchanged.

* gamescope: a constraint without a hint is relative only while the game holds the X pointer

Xwayland turns a hidden-cursor grab into a lock with no cursor position hint, so the confine-only rule never applied to games that draw their own cursor (Stardew Valley, Balatro). A hinted constraint is relative at once, as upstream. An unhinted lock or confine goes relative once the X pointer stops following the seat's motion, which a recentring or clipping game does and a drawn-cursor game does not.

* gamescope: a pointer constraint is relative only while the game holds the X pointer

Stardew Valley's grab with a hidden cursor becomes an Xwayland lock with a cursor position hint, which upstream made relative at once. Without relative mode that lock still follows gamescope's relative motion and touches still place the pointer, so the hold rule now decides for every constraint, hinted or not.

* gamescope: a hinted lock starts relative and is released once the X pointer follows

Releasing a hinted lock only through the hold rule let a recentring game's first touch swipe press the button. A hinted lock now starts relative as upstream and is released after 30 moving frames on which the X pointer follows the motion gamescope sends, counted from the relative motion while the seat is locked.

* gamescope: count away frames within one half-second window

* gamescope: a hint that arrives with the game's first warp starts the lock relative

* fix(input): end the touch press when a program locks the pointer mid-swipe

A game that only shows its pointer lock after its first warp took the first
swipe as an absolute drag, with the button held until the finger lifted.
The first locked move now ends the forwarded touch before sending deltas.

* gamescope: a new focus window or constraint starts its own pointer hold

A held state from one constrained window carried over to the next one
when focus moved before a reset frame. Track the input focus window and
the constraint, and restart the hold when either changes.

* gamescope: read the pointer constraint under the wlserver lock

GetCursorConstraint asserts the lock is held, so the hold tracker
aborted as soon as a program constrained the pointer.

* fix(input): a mid-swipe pointer lock ends the touch but keeps a held button

The first locked move cancelled the whole direct-touch hold, releasing a
right button held by a long press. It now cancels only the forwarded touch;
the held button is released when the finger lifts, as before.

* fix(input): extra fingers during a locked swipe stay out of the cancelled touch

Once a locked swipe has ended the forwarded touch, a second finger restarted
it with a new touch-down while the swipe carried on as relative motion. Extra
fingers are now ignored until the swipe ends; the final lift still releases a
held button.

* fix(input): a swipe that ended its touch stays closed until the finger lifts
2026-11-07 21:30:21 -05:00
MaxsTechReview 0d034b1996 fix(input): restore the rest of what #1439 removed, and keep the editor on the bottom screen (#2242)
* fix(session): close the bottom panel from the docked menu's Close again

* fix(input): keep imported screen keys on screen when the gamepad is hidden

Screen keys used to be shown on their own, whatever the gamepad did. Since
they were merged into the gamepad layout they disappeared with a controller
connected, in desktop sessions, with on-screen controls set to Never, in
Steam + QAM mode and in Steam's touch mode.

Elements now carry an always-shown flag. The screen key migration sets it on
imported keys (and once on keys merged by earlier builds), and the on-screen
controls keep flagged elements drawn while the rest of the pad is hidden,
in buttons-only mode and with the quick toggle, without duplicating them in
the unfolded strip.

* fix(dual-screen): give the profile controls panel the bottom bar with swap

* fix(dual-screen): keep the layout editor on the bottom screen

The bottom panel was recreated above an open input editor or input
settings window whenever it closed and reopened (session stop/start, or
the bottom screen briefly reported as taken), hiding the editor. The panel
now waits while one of our input windows holds its display and reopens
when the last one closes.

An app arrival recorded before one of our windows covered the bottom
screen no longer marks the screen taken, the editor picks its display only
when a display is added or removed, and it skips a bottom screen another
app holds.

* fix(input): label keyed D-pads and sticks with their keys

Screen key D-pads and sticks used to show each direction's key (W, A, S, D)
when labels were on. The unified renderer drew no direction labels and only
offered Show label for buttons and gamepad sticks, so imported labels did
nothing.

D-pads and sticks bound to directional keys now draw each bound key's name
at its direction through the shared key-label helper, and Show label is
offered for every D-pad and stick.

* fix(input): remember the layout editor's snap choice

* refactor(input): drop controller pref setters and drawer field nothing uses

* fix(input): restore the Deck tabs and right trackpad in the unfolded strip

With Deck controls, the unfolded strip used to offer the Gamepad, Grips,
Left, Right and Both tabs and put a trackpad in the right stick slot. The
unified strip only showed the gamepad with two sticks.

For Deck pads the strip bar now carries the shared Deck panel tabs and
title, the other tabs show the same stock Deck panel layouts as the second
screen, and the docked gamepad takes its stick or trackpad per side from the
Deck gamepad layout. Other pads keep the plain two-stick strip.

* fix(input): carry the Deck gamepad stick or trackpad choice into its layout

The Deck controls Gamepad tab used to let each side switch between a stick
and a trackpad. That choice is now part of the editable Deck gamepad
layout, but the saved choice was never moved over and its setter was left
without callers.

The library upgrade now applies a saved non-default choice to the Deck
gamepad layout once, only while that layout is still the stock one, and the
unused setter is removed.

* chore(i18n): drop the Deck surface switch string nothing shows

* fix(input): offer Show label only where a label can be drawn

* fix(input): Show label follows the unsaved action and diagonal-only sticks

The element properties now get the editor's draft action, so changing a
stick or D-pad to keys offers Show label before saving. A stick bound only
on its diagonals counts as labelled too.

* fix(input): only sticks count their diagonal keys for Show label

Diagonal key names are drawn on sticks only, so a D-pad bound just on its
diagonals no longer offers a label it can't show.
2026-11-07 21:04:34 -05:00
203 changed files with 5053 additions and 1371 deletions

No files matched your search

+12 -12
View File
@@ -313,6 +313,11 @@ jobs:
- name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in
run: python3 tools/components/prepare.py dirt-ws2
- name: Cache graphics driver inputs
uses: actions/cache@v4
with:
path: app/build/quest-inputs
key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }}
- name: Prepare graphics driver variants
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
- name: Record and pack prepared components
@@ -558,9 +563,11 @@ jobs:
- name: Cache checksum-pinned pack inputs
uses: actions/cache@v4
with:
path: ~/.cache/droiddeck-pack-inputs
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
restore-keys: pack-inputs-v1-
path: |
~/.cache/droiddeck-pack-inputs
~/.cache/droiddeck-build
key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }}
restore-keys: pack-inputs-v2-
- name: Build the add-on packs
run: |
python3 tools/powervr/fetch.py
@@ -785,18 +792,11 @@ jobs:
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
python-runner:
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
uses: ./.github/workflows/pick-runner.yml
secrets:
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
python-tests:
name: Python and native regression tests
needs: [source, python-runner]
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ fromJSON(needs.python-runner.outputs.labels) }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
+23 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 2
outputs:
labels: ${{ steps.runner.outputs.labels || '"ubuntu-24.04"' }}
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
steps:
- name: Pick the compile runner
id: runner
@@ -36,3 +36,25 @@ jobs:
fi
echo "labels=$labels" >> "$GITHUB_OUTPUT"
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
- name: Pick the fork runner
id: fork
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
labels='"ubuntu-24.04"'
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
echo "Heartbeat: ${IDLE_AT:-none}"
age=-1
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
age=$(( $(date +%s) - IDLE_AT ))
fi
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
labels='"droiddeck-fork"'
fi
echo "labels=$labels" >> "$GITHUB_OUTPUT"
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
+6 -5
View File
@@ -7,10 +7,10 @@
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
<p align="center">
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.7.1%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.7.1"></a>
<a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a>
</p>
@@ -36,9 +36,9 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
## Install
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx is experimental. Mali, Xclipse, PowerVR and Adreno 710 are unsupported. No root needed.
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed.
2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators.
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk), install the Linux runtime, press **Play** and sign in to Steam.
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk), install the Linux runtime, press **Play** and sign in to Steam.
4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it.
Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases).
@@ -79,7 +79,8 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
- The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest).
+2 -2
View File
@@ -131,8 +131,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 17
versionName "0.6.9"
versionCode 19
versionName "0.7.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
+8 -8
View File
@@ -91,14 +91,6 @@
</intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
</service>
<service android:name=".session.BottomNotificationListener"
android:label="@string/bottom_notifications_listener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
android:process=":shortcuts_sync">
<intent-filter>
@@ -352,6 +344,14 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<receiver
android:name=".agent.AgentCallReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="${applicationId}.AGENT_CALL" />
</intent-filter>
</receiver>
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
+27 -16
View File
@@ -30,6 +30,7 @@
#include <linux/uinput.h>
#include <poll.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdarg.h>
#include <stdbool.h>
@@ -208,23 +209,33 @@ struct FakeConfig {
bool uinput_enabled;
};
// The first allocation here can start the host program's own allocator, which may call a hooked
// function (mimalloc probes NUMA nodes with access()) and land back in config(). A guarded static
// would throw a recursive-init error there, so the fields are filled without allocating, and a
// reentry on the initializing thread gets them while other threads wait for the fork handlers.
static FakeConfig &config() {
static FakeConfig *cfg = [] {
auto *c = new FakeConfig();
const char *dir = getenv("FAKE_EVDEV_DIR");
c->hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
c->udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
c->vibration_enabled =
getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
c->xbox360_identity = identity && !strcmp(identity, "xbox360");
c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
pthread_atfork(controller_fork_prepare, controller_fork_parent,
controller_fork_child);
Logger::init();
return c;
}();
return *cfg;
static FakeConfig cfg;
static std::atomic<int> state{0};
static __thread bool initializing __attribute__((tls_model("initial-exec")));
if (state.load(std::memory_order_acquire) == 2 || initializing) return cfg;
int expected = 0;
if (!state.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
while (state.load(std::memory_order_acquire) != 2) sched_yield();
return cfg;
}
initializing = true;
const char *dir = getenv("FAKE_EVDEV_DIR");
cfg.hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
cfg.udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
cfg.vibration_enabled = getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
cfg.xbox360_identity = identity && !strcmp(identity, "xbox360");
cfg.uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child);
Logger::init();
state.store(2, std::memory_order_release);
initializing = false;
return cfg;
}
static std::recursive_mutex &controller_mutex() {
@@ -213,7 +213,7 @@ static struct {
} g_map;
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
static float g_game_picture_rect[4];
static int g_game_picture_top;
static int g_game_picture_placed;
static int64_t g_swap_retry_at_ns;
static int g_swap_fail_logged;
@@ -362,7 +362,7 @@ int vkp_completion_unknown(void) { return g_dev_state == -3; }
void vk_present_set_scale_mode(int mode, int alignment) {
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_RIGHT) alignment = VKP_ALIGN_CENTER;
g_mode = mode;
g_align = alignment;
}
@@ -383,6 +383,8 @@ static const char *align_name(int a) {
case VKP_ALIGN_BOTTOM: return "bottom";
case VKP_ALIGN_PICTURE_TOP: return "picture top";
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
case VKP_ALIGN_PICTURE_LEFT: return "picture left";
case VKP_ALIGN_PICTURE_RIGHT: return "picture right";
default: return "center";
}
}
@@ -420,9 +422,11 @@ static void update_map(int scene_w, int scene_h) {
}
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
else if (align == VKP_ALIGN_PICTURE_LEFT) g_map.off_x = 0;
else if (align == VKP_ALIGN_PICTURE_RIGHT) g_map.off_x = W - scene_w * g_map.kx;
g_map.valid = 1;
pthread_mutex_lock(&g_game_picture_lock);
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
g_game_picture_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT;
g_game_picture_rect[0] = g_map.off_x;
g_game_picture_rect[1] = g_map.off_y;
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
@@ -436,7 +440,7 @@ static void update_map(int scene_w, int scene_h) {
int vkp_game_picture_rect(float rect[4]) {
pthread_mutex_lock(&g_game_picture_lock);
int active = g_game_picture_top;
int active = g_game_picture_placed;
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
pthread_mutex_unlock(&g_game_picture_lock);
return active;
@@ -15,7 +15,9 @@ int vkp_quest_layer_presentation(void);
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
VKP_MODE_INTEGER = 4 };
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4,
VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 };
void vk_present_set_scale_mode(int mode, int alignment);
int vkp_game_picture_rect(float rect[4]);
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
@@ -603,11 +603,19 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
}
fun selectDriver(id: String, linux: Boolean) {
if (!linux && id == TurnipDriver.AUTO) {
DriverBundle.selectDisplay(activity, id)
pairModeOpen = false
lastAutoCheck = 0L
setMode(auto = true)
refreshDrivers()
return
}
val bundle = DriverBundle.containing(activity, id, linux)
when {
bundle != null -> useBundle(bundle)
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
else -> DriverBundle.selectDisplay(activity, id)
}
pairModeOpen = false
setMode(auto = false, mix = true)
@@ -23,6 +23,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -873,6 +874,7 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
Library.recordPlayed(this, game)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
.returnTo(returnHome)
@@ -888,7 +890,15 @@ class FrontEndActivity : ComponentActivity() {
putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
}
}, steamSession = true)
}
.playing(game), steamSession = true)
}
private fun Intent.playing(game: Library.SteamGame): Intent =
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
private fun recordPlayed(intent: Intent) {
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
}
private fun Intent.returnTo(returnHome: Boolean): Intent {
@@ -915,14 +925,17 @@ class FrontEndActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
.playing(game)
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
recordPlayed(next)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
else -> pendingSwitch = next
}
}
@@ -1123,6 +1136,10 @@ class FrontEndActivity : ComponentActivity() {
applyLauncherFullscreen()
updates.start()
setContent {
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(prootPack) {
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
}
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
@@ -2276,14 +2293,14 @@ class FrontEndActivity : ComponentActivity() {
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
handToBattleNet(full) { pendingSwitch = next } -> {}
handToBattleNet(full, next) -> {}
else -> pendingSwitch = next
}
}
}, "battlenet-open").start()
}
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
@@ -2294,10 +2311,13 @@ class FrontEndActivity : ComponentActivity() {
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
ui.post {
if (isDestroyed) return@post
if (!handed) otherwise()
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
if (!handed) pendingSwitch = next
else {
recordPlayed(next)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
}
}, "battlenet-request").start()
return true
@@ -121,6 +121,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionLoadingDecision
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
@@ -172,12 +173,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
"top" -> ALIGN_PICTURE_TOP
"bottom" -> ALIGN_PICTURE_BOTTOM
"left" -> ALIGN_PICTURE_LEFT
"right" -> ALIGN_PICTURE_RIGHT
else -> ALIGN_CENTER
}
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
}
private var unfoldedPanel by mutableStateOf<OnScreenControls?>(null)
private var unfoldedColumn: android.widget.LinearLayout? = null
private var unfoldedDeckSurface: com.droiddeck.launcher.ui.InputSurfaceView? = null
private var unfoldedDeckPanel by mutableStateOf<com.droiddeck.launcher.input.DeckPanel?>(null)
private var deckPanelLayouts: Map<com.droiddeck.launcher.input.DeckPanel, com.droiddeck.launcher.input.SavedInputLayout?> = emptyMap()
private lateinit var sessionRoot: FrameLayout
private var inputRouter: InputRouter? = null
private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null
@@ -283,6 +289,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK)
private var relativeMouseMode by mutableIntStateOf(0)
@Volatile private var appliedRelativeMouseMode: Int? = null
private val agentReadyListener: () -> Unit = { relativeMouseRetries = 0; requestRelativeMouse() }
private val appTextFocus get() = wowChat.request != null
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
private var wowChatBottom: SecondScreenPresentation? = null
@@ -565,6 +572,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val inputWindowDisplays = mutableListOf<Int>()
private val bottomScreenListener: () -> Unit = {
if (secondScreenPresentation?.display?.displayId?.let(com.droiddeck.launcher.input.DualScreen::taken) == true) closeSecondScreen(reset = false)
updateBottomGame()
@@ -632,6 +640,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.syncConnectedControllers()
refreshControls()
updatePointerCapture()
requestRelativeMouse()
updateHandCamera()
refreshSecondScreenDisplays()
}
@@ -647,12 +656,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::padMouse.isInitialized) padMouse.reset()
refreshControls()
updatePointerCapture()
requestRelativeMouse()
updateHandCamera()
refreshSecondScreenDisplays()
}
override fun onInputDeviceChanged(deviceId: Int) {
joyConVr?.refresh()
refreshControls()
requestRelativeMouse()
refreshSecondScreenDisplays()
}
}
@@ -661,6 +672,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AgentGuest.readyListener = agentReadyListener
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
@@ -977,7 +989,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
} else {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay({ cursorPos }, cursorVisible && !uiPointer && !bottomOutputActive,
CursorOverlay({ cursorPos }, cursorVisible && (!uiPointer || onlyPcKeyboardOpen()) && !bottomOutputActive,
resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (memoryHud.text.isNotEmpty()) HudText(memoryHud.text, hudColor, hudOpacity)
@@ -1222,7 +1234,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
oscMode = oscMode, suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
micAvailable = SessionState.micAvailable && !SessionState.suspended,
micEnabled = SessionState.micEnabled,
@@ -1443,6 +1455,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
},
onClose = { drawerOpen = false },
onDismissScreen = if (docked) ({ selectSecondScreenMode(SecondScreenMode.NONE) }) else null,
components = drawerComponents,
onComponentsRefresh = {
refreshDrawerComponents()
@@ -1955,6 +1968,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextRoutedTouchMode = effectiveTouchMode()
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
routedTouchMode = nextRoutedTouchMode
requestRelativeMouse()
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -2225,6 +2239,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
sessionActivityDisplayId,
)
intent.getStringExtra(EXTRA_PLAYED_GAME)?.let { com.droiddeck.launcher.frontend.Library.recordPlayed(this, it) }
}
applyFrameGen()
applyGameAlignment()
@@ -2315,9 +2330,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
if (sessionTouchDown) dismissTouch = true
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
@@ -2421,6 +2438,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun watchSession() {
val handler = Handler(Looper.getMainLooper())
var ticks = 0
val loadingStartedAt = SystemClock.elapsedRealtime()
val poll = object : Runnable {
override fun run() {
if (!watching) return
@@ -2433,6 +2451,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
finish()
return
}
if (loading.visible && !loading.ended && !SessionState.running && !installingRuntime &&
SessionLoadingDecision.shouldReturnToLauncher(
loadingStartedAt, SystemClock.elapsedRealtime(), SessionState.running, SessionState.phase)) {
finish()
return
}
if (loading.visible && !loading.ended) {
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
if (ticks++ % 2 == 0) loading.tick()
@@ -2457,8 +2481,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val hint = sessionExitHint(status)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
if (status == 0) SessionState.relaunch?.let { next ->
SessionState.relaunch = null
if (status == 0) SessionState.takeRelaunch()?.let { next ->
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
setIntent(next)
@@ -2826,6 +2849,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return onScreenControls?.let { takes(it, it::takesTouchAt) } == true ||
steamTouchControls?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedDeckSurface?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedColumn?.getChildAt(0)?.let { takes(it) { _, _ -> true } } == true
}
@@ -2972,7 +2996,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun applyRelativeMouse() {
val mode = desiredRelativeMouseMode
val wanted = desiredRelativeMouseMode
val touchpadRoute = com.droiddeck.launcher.input.TouchRouter.decide(com.droiddeck.launcher.input.TouchRouter.Snapshot(
uiBlocking = false,
mode = routedTouchMode,
scrollLayer = false,
steamSession = SessionState.mode == SessionService.MODE_STEAM,
steamDesktopUi = SessionState.steamUi == "desktop",
)) == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD
val mode = com.droiddeck.launcher.input.TouchGestureConfig.gamescopeRelativeMouse(wanted, mouseAttached(), touchpadRoute)
if (appliedRelativeMouseMode == mode) return
val request = org.json.JSONObject()
.put("kind", "exec")
@@ -2988,7 +3020,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.w(TAG, "gamescope did not take relative mouse $mode: ${result.exceptionOrNull() ?: answer}")
if (relativeMouseRetries++ < RELATIVE_MOUSE_MAX_RETRIES) uiHandler.postDelayed({ requestRelativeMouse() }, 2_000)
}
if (desiredRelativeMouseMode != mode) requestRelativeMouse()
if (desiredRelativeMouseMode != wanted) requestRelativeMouse()
}
private fun refreshGestureConfig() {
@@ -3183,12 +3215,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
cursorVisible = false
return
}
val rect = drawnRect() ?: return
val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect()
val pointerRect = rect ?: return
val position = cursorPos
val x = if (position.x >= 0f) position.x.coerceIn(rect.left, rect.right) else rect.centerX()
val y = if (position.y >= 0f) position.y.coerceIn(rect.top, rect.bottom) else rect.centerY()
val x = if (position.x >= 0f) position.x.coerceIn(pointerRect.left, pointerRect.right) else pointerRect.centerX()
val y = if (position.y >= 0f) position.y.coerceIn(pointerRect.top, pointerRect.bottom) else pointerRect.centerY()
val out = SessionState.outputSize
movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second)
val nextX = (x + dx * pointerRect.width() / out.first)
.coerceIn(pointerRect.left, pointerRect.right)
val nextY = (y + dy * pointerRect.height() / out.second)
.coerceIn(pointerRect.top, pointerRect.bottom)
movePointer(nextX, nextY)
// Make frame-callback updates visible to drawing before this frame's traversal.
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
@@ -3378,6 +3415,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
if (target.displayId in inputWindowDisplays) {
panelMode(mode)
return
}
dropDockedDrawer()
presentation?.closeControls()
presentation = secondScreenPresentation(target)
@@ -3432,50 +3473,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun bottomPages(): List<BottomPage> = sessionBottomPages(BottomPageActions(
takeScreenshot = ::captureScreenshot,
fps = { if (::hud.isInitialized) hud.liveFps else null },
openSettings = ::openSettingsBelow,
runningGame = { gestureGame },
))
private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
val presentation = secondScreenPresentation
if (presentation == null) {
if (!bottomOutputActive) return
listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
runCatching { startActivity(android.content.Intent(it)) }
.onFailure { e -> Log.w(TAG, "could not open ${it.action}", e) }
.isSuccess
}
return
}
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
val opened = listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
runCatching { startActivity(android.content.Intent(it).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) }
.onFailure { e -> Log.w(TAG, "could not open ${it.action} on display ${presentation.display.displayId}", e) }
.isSuccess
}
if (!opened) return
presentation.hide()
var checks = 0
fun showAgain() {
try {
presentation.show()
} catch (e: Exception) {
Log.w(TAG, "could not show the dashboard again", e)
if (secondScreenPresentation === presentation) closeSecondScreen(reset = true)
}
}
uiHandler.postDelayed(object : Runnable {
override fun run() {
if (secondScreenPresentation !== presentation || presentation.isShowing) return
if (done()) {
showAgain()
return
}
if (++checks < SETTINGS_WATCH_CHECKS) uiHandler.postDelayed(this, SETTINGS_WATCH_MS) else showAgain()
}
}, SETTINGS_WATCH_MS)
}
private fun captureScreenshot(onDone: (Boolean) -> Unit) {
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (bottomOutputActive) bottomGame?.surfaceView
else if (::surfaceView.isInitialized) surfaceView else null
@@ -3518,8 +3518,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val main = window.decorView.display
if (role == InputRole.PRIMARY) return bottomGame?.display ?: main
if (bottomGame != null) return main
return displayManager.getDisplay(selectedSecondScreenDisplay)
?: secondScreenDisplays.firstOrNull()?.let { displayManager.getDisplay(it.id) }
return com.droiddeck.launcher.input.SecondScreenDisplays.editorCandidates(selectedSecondScreenDisplay,
secondScreenDisplays.map { it.id }, com.droiddeck.launcher.input.DualScreen::taken).firstNotNullOfOrNull(displayManager::getDisplay)
}
fun inputWindowShown(displayId: Int) { inputWindowDisplays += displayId }
fun inputWindowClosed(displayId: Int) {
inputWindowDisplays -= displayId
if (started && displayId !in inputWindowDisplays && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
}
private fun refreshInputProfile() {
@@ -3530,6 +3537,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
adaptiveSticks = primaryInputLayout?.adaptiveSticks
quickButtons = primaryInputLayout?.takeIf { it.editable }?.let { l -> l.quickButtons.associateWith { it !in l.style.hidden } }.orEmpty()
secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.panel(this))
deckPanelLayouts = com.droiddeck.launcher.input.DeckPanel.entries.associateWith { library.panelLayout(desktop, it.bindings) }
inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native }
inputUsesPointer = inputProfile.bindings.values.any { bindings ->
bindings.values.any { it is InputAction.Pointer || it is InputAction.MouseButton || it is InputAction.Scroll }
@@ -3543,6 +3551,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls?.updateLayout(primaryInputLayout)
bottomInputControls?.updateLayout(primaryInputLayout)
unfoldedPanel?.updateLayout(primaryInputLayout)
if (unfoldedPanel != null) applyUnfoldedControls()
showUnfoldedDeckPanel()
updateOnScreenPad()
secondScreenPresentation?.refreshControls()
}
@@ -3586,7 +3596,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = controlsBelowWanted()
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
val tabRow = unfoldedColumn?.getChildAt(0)?.height?.let {
it - (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()
} ?: 0
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height, tabRow)
else FrameLayout.LayoutParams.MATCH_PARENT
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
if (params.height != gameHeight) {
@@ -3594,11 +3607,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
else "unfolded controls: game full screen")
}
val dock = com.droiddeck.launcher.input.OnScreenLayout.Dock(!com.droiddeck.launcher.session.SessionDisplay.foldable(this), SessionState.deckPad)
val portrait = !com.droiddeck.launcher.session.SessionDisplay.foldable(this)
val dock = if (SessionState.deckPad) com.droiddeck.launcher.input.OnScreenLayout.Dock.deck(portrait, deckPanelLayouts[com.droiddeck.launcher.input.DeckPanel.GAMEPAD])
else com.droiddeck.launcher.input.OnScreenLayout.Dock(portrait, false)
var panel = unfoldedPanel
if (panel != null && (!wanted || panel.dock != dock)) {
sessionRoot.removeView(unfoldedColumn)
unfoldedColumn = null
unfoldedDeckSurface = null
unfoldedDeckPanel = null
unfoldedPanel = null
panel = null
if (!wanted) updateOnScreenControls()
@@ -3608,21 +3625,47 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
panel = OnScreenControls(this, padBridge, dock).also { it.updateLayout(primaryInputLayout) }
val column = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
addView(unfoldedBar(), android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
(com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()))
addView(unfoldedBar().apply {
addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
if (bottom - top != oldBottom - oldTop) post { applyUnfoldedControls() }
}
}, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT))
addView(panel, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1)
unfoldedColumn = column
unfoldedPanel = panel
showUnfoldedDeckPanel()
updateOnScreenControls()
}
unfoldedColumn?.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
}
private fun unfoldedBar(): View = com.droiddeck.launcher.ui.bottomScreenBarView(this) {
com.droiddeck.launcher.ui.sessionBarModel(getString(R.string.deck_tab_gamepad), { chooseUnfoldedControls(false) },
{ drawerOpen = true }, ::tapSteamButton, ::tapQam)
val deck = unfoldedDeckPanel
com.droiddeck.launcher.ui.sessionBarModel(getString(if (deck != null) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad),
{ chooseUnfoldedControls(false) }, { drawerOpen = true }, ::tapSteamButton, ::tapQam,
tabs = deck?.let { com.droiddeck.launcher.ui.deckPanelTabs(this, it, ::selectDeckPanel) })
}
private fun showUnfoldedDeckPanel() {
val column = unfoldedColumn
val panel = unfoldedPanel
val selected = SessionPrefs.deckPanel(this).takeIf { column != null && panel?.dock?.grips == true }
unfoldedDeckPanel = selected
val layout = selected?.takeIf { it != com.droiddeck.launcher.input.DeckPanel.GAMEPAD }?.let { deckPanelLayouts[it] }
if (layout != null && unfoldedDeckSurface?.tag == layout) return
unfoldedDeckSurface?.let { it.releaseInputs(); column?.removeView(it) }
unfoldedDeckSurface = null
if (column == null || panel == null) return
if (layout == null) { panel.visibility = View.VISIBLE; return }
panel.releaseAll()
panel.visibility = View.GONE
unfoldedDeckSurface = com.droiddeck.launcher.ui.InputSurfaceView(this, padBridge, layout.elements, InputRole.PRIMARY, layout.style, haptics = true).also {
it.tag = layout
column.addView(it, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
}
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
@@ -3798,6 +3841,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
gestureTouchStreamGate.reset()
routedTouchMode = next
requestRelativeMouse()
if (directTouchCursorHidden()) cursorVisible = false
}
@@ -3890,8 +3934,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val width = outputView().width.takeIf { it > 0 } ?: return null
val height = outputView().height.takeIf { it > 0 } ?: return null
topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds)
if (topPictureMapped) return android.graphics.RectF(
gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
if (topPictureMapped) {
val rect = android.graphics.RectF(
gamePictureBounds[0], gamePictureBounds[1],
gamePictureBounds[2], gamePictureBounds[3]
)
if (glassesOutputActive) {
val mode = glassesScreen?.display?.mode ?: return null
val outputWidth = mode.physicalWidth.takeIf { it > 0 } ?: return null
val outputHeight = mode.physicalHeight.takeIf { it > 0 } ?: return null
val scaleX = width.toFloat() / outputWidth
val scaleY = height.toFloat() / outputHeight
return android.graphics.RectF(
rect.left * scaleX,
rect.top * scaleY,
rect.right * scaleX,
rect.bottom * scaleY
)
}
return rect
}
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
@@ -3918,9 +3983,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var lockedSwipeX = 0f
private var lockedSwipeY = 0f
private var dismissTouch = false
private var sessionTouchDown = false
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
sessionTouchDown = true
dismissTouch = false
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> sessionTouchDown = false
}
if (drawerOpen && drawerDocked && event.actionMasked == MotionEvent.ACTION_DOWN) {
drawerLayerOpen = false
drawerOpenState = false
@@ -3932,7 +4005,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) dismissTouch = false
return true
}
val uiBlocking = drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
val uiBlocking = drawerOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
if (event.actionMasked == MotionEvent.ACTION_DOWN && !uiBlocking && routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -3960,7 +4033,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.input.TouchRouter.Route.SCROLL -> scrollTouch.onTouch(event)
else -> {
if (com.droiddeck.launcher.input.TouchRouter.steamKeyboardZone(snapshot)) {
val wasActive = threeFingerTap.active
val wasActive = threeFingerTap.active && event.actionMasked != MotionEvent.ACTION_DOWN
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
if (threeFingerTap.active && !wasActive) {
if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) touchpad.cancel()
@@ -4036,9 +4109,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) lockedSwipeUsed = false
if (lockedSwipeUsed && (event.actionMasked != MotionEvent.ACTION_MOVE || !programLocksPointer())) return true
if (programLocksPointer() && event.actionMasked == MotionEvent.ACTION_MOVE) {
if (lockedSwipeUsed) directTouchHold.cancelPendingHold() else directTouchHold.cancelTouch()
lockedSwipeUsed = true
directTouchHold.cancelPendingHold()
val scale = sceneScale(rect)
sendSceneDelta((event.x - swipeX) * scale, (event.y - swipeY) * scale)
return true
@@ -4155,6 +4229,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyPlatformPointer(appUiOwnsPointer())
}
private fun onlyPcKeyboardOpen(): Boolean =
pcKeyboardOpen && !drawerOpen && !achievementsOpen && !inputSettingsOpen && !pipUi && !glassesOutputActive && !touchEditorOpen && !wowChatOnTop
private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
@@ -4245,7 +4322,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenPad()
val c = onScreenControls
glassesSlot = when {
c == null || c.visibility != View.VISIBLE -> 0
c == null || c.visibility != View.VISIBLE || c.isPinnedOnly -> 0
c.isButtonsOnly -> -1
primaryInputLayout?.let { l -> l.elements.any { it.quickButton == InputCommand.KEYBOARD && !l.style.hides(it) } }
?: (controllerSettings?.keyboardButton != false) -> 2
@@ -4260,12 +4337,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
fun showPinned() {
controls.setPinnedOnly(true)
val pinned = controls.hasPinned && !pipUi && !inputSettingsOpen
if (!pinned) controls.releaseAll()
controls.visibility = if (pinned) View.VISIBLE else View.GONE
}
if (pipUi || unfoldedPanel != null || pcKeyboardOpen || inputSettingsOpen ||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
onScreenButtonsVisible = false
hideBottomControls()
controls.releaseAll()
controls.visibility = View.GONE
showPinned()
steamTouchControls?.let { it.cancelEditing(); it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
@@ -4298,7 +4380,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val drawn = show && !keyboardCoversPad()
if (steam != null) {
hideBottomControls()
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
showPinned()
if (!drawn) { steam.cancelEditing(); steam.releaseAll() }
steam.visibility = if (drawn) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
@@ -4307,7 +4389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
val bottomRoot = bottomGame?.surfaceView?.parent as? android.view.ViewGroup
if (drawn && bottomRoot != null) {
if (bottomRoot != null && (drawn || controls.hasPinned)) {
controls.releaseAll(); controls.visibility = View.GONE
var bottom = bottomInputControls
if (bottom?.parent !== bottomRoot) {
@@ -4320,13 +4402,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
bottom.updateLayout(primaryInputLayout)
bottom.setButtonsOnly(buttonsOnly)
bottom.setPinnedOnly(!drawn)
bottom.visibility = View.VISIBLE
return
}
hideBottomControls()
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
val wasDrawn = controls.visibility == View.VISIBLE && !controls.isPinnedOnly
if (!drawn) showPinned() else { controls.setPinnedOnly(false); controls.visibility = View.VISIBLE }
if (drawn == wasDrawn) return
Log.i(TAG, "on-screen controls " + when {
!drawn -> "hidden"
buttonsOnly -> "Steam + QAM"
@@ -5069,7 +5152,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.KEYBOARD_TRACKPAD.label), listOf(keyboardExtra)))
SecondScreenMode.DECK_CONTROLS -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.DECK_CONTROLS.label),
tabs = com.droiddeck.launcher.ui.deckPanelTabs(this@SessionActivity, SessionPrefs.deckPanel(this@SessionActivity), ::selectDeckPanel)))
else -> Unit
else -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(secondScreenMode.label)))
}
androidx.compose.runtime.key(profileLayout) {
androidx.compose.ui.viewinterop.AndroidView(
@@ -5286,6 +5369,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
if (AgentGuest.readyListener === agentReadyListener) AgentGuest.readyListener = null
com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this)
relativeMouseRequests.shutdownNow()
backQamInput.cancel()
@@ -5390,13 +5474,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_LINK_TASK = "linkTask"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_PLAYED_GAME = "com.droiddeck.launcher.PLAYED_GAME"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TEXT_FIELD_TAP_MS = 1_000L
private const val PAUSED_FRAME_MAX_EDGE = 1920f
private const val PAUSED_FRAME_REDRAW_MS = 150L
private const val SETTINGS_WATCH_MS = 1_000L
private const val SETTINGS_WATCH_CHECKS = 600
private const val BOTTOM_RECOVERY_MS = 1_000L
private const val BOTTOM_RECOVERY_TRIES = 3
private const val TAG = "SessionActivity"
@@ -5438,6 +5521,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val ALIGN_CENTER = 0
private const val ALIGN_PICTURE_TOP = 3
private const val ALIGN_PICTURE_BOTTOM = 4
private const val ALIGN_PICTURE_LEFT = 5
private const val ALIGN_PICTURE_RIGHT = 6
private val RUNTIME_LINES = PackageLines(
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
@@ -0,0 +1,41 @@
package com.droiddeck.launcher.agent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import java.io.File
/**
* The agent bridge for devices whose shell has no `content` command: the same calls as
* [AgentBridgeProvider], answered in a file under the app's external files folder.
*/
class AgentCallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val method = intent.getStringExtra("method") ?: return
val id = intent.getStringExtra("id")?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return
val arg = intent.getStringExtra("arg")
val request = intent.getStringExtra(AgentBridgeProvider.EXTRA_REQUEST)
val pending = goAsync()
Thread({
val json = try {
val extras = Bundle().apply { putString(AgentBridgeProvider.EXTRA_REQUEST, request) }
context.contentResolver.call("${context.packageName}.agent", method, arg, extras)
?.getString(AgentBridgeProvider.RESULT_JSON) ?: """{"ok":false,"error":{"code":"NO_RESPONSE","message":"no response"}}"""
} catch (e: Exception) {
org.json.JSONObject().put("ok", false)
.put("error", org.json.JSONObject().put("code", "COMMAND_FAILED").put("message", e.message ?: e.javaClass.simpleName)).toString()
}
try {
val dir = File(context.getExternalFilesDir(null), "agent-calls").apply { mkdirs() }
File(dir, "$id.json.part").writeText(json)
File(dir, "$id.json.part").renameTo(File(dir, "$id.json"))
} catch (e: Exception) {
android.util.Log.w("AgentCallReceiver", "could not write the answer to $method: ${e.message}")
} finally {
pending.finish()
}
}, "agent-call-$id").start()
}
}
@@ -33,6 +33,7 @@ object AgentGuest {
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@Volatile var readyListener: (() -> Unit)? = null
@Volatile private var routeDir: File? = null
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
@@ -59,6 +60,7 @@ object AgentGuest {
when (path) {
"hello.json" -> readJson(File(dir, path))?.let {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
readyListener?.invoke()
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
@@ -18,9 +18,10 @@ object Library {
val source: String = SOURCE_STEAM,
val storeId: String? = null,
val logo: File? = null,
val installedAt: Long = 0L,
) {
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
@@ -31,6 +32,7 @@ object Library {
}
}
const val ADDED = "added"
private const val PLAYED = "added_games_played"
const val EPIC_LAUNCHER = "epic-launcher"
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
const val BATTLENET_LAUNCHER = "battlenet-launcher"
@@ -221,6 +223,18 @@ object Library {
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
}
fun recordPlayed(context: Context, game: SteamGame) {
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
}
fun recordPlayed(context: Context, gameId: String) {
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
}
internal fun recordedPlay(context: Context, gameId: Long): Long =
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
val config = AddedGames.accountConfig(root) ?: return emptyMap()
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
@@ -249,12 +263,13 @@ object Library {
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, lastPlayed = lastPlayed, gameFiles = g.folder,
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
logo = art.logo,
installedAt = g.folder.lastModified(),
)
}
@@ -27,6 +27,7 @@ object LibraryCache {
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
logo = o.file("logo"),
installedAt = o.optLong("installedAt"),
)
} to x64
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
@@ -44,7 +45,7 @@ object LibraryCache {
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -159,6 +159,12 @@ object DriverBundle {
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
}
fun selectDisplay(context: Context, id: String) {
val left = active(context)
SessionPrefs.setAndroidDriver(context, id)
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
}
fun remove(context: Context, bundle: Bundle) {
TurnipDriver(context).remove(bundle.androidId)
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
@@ -191,6 +191,7 @@ public final class SystemVulkanDriver {
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
if (!AddonPack.POWERVR.installed(context)) return;
pruneUnused(runtimeDirectory(context), guestsStopped);
}
@@ -6,6 +6,7 @@ import kotlin.math.roundToInt
internal class FrameStats {
private val graph = FloatArray(GRAPH_FRAMES)
private var graphHead = 0
private var graphFrames = 0L
private var lastPresentNs = 0L
private var lastFpsUpdateNs = 0L
private var framesSinceUpdate = 0
@@ -38,6 +39,7 @@ internal class FrameStats {
if (lastPresentNs != 0L) {
graph[graphHead] = frametimeNs / 1e6f
graphHead = (graphHead + 1) % GRAPH_FRAMES
graphFrames++
}
val elapsed = timeNs - lastFpsUpdateNs
if (elapsed >= FPS_PERIOD_NS) {
@@ -68,6 +70,7 @@ internal class FrameStats {
fun reset() {
graph.fill(0f)
graphHead = 0
graphFrames = 0L
lastPresentNs = 0L
lastFpsUpdateNs = 0L
framesSinceUpdate = 0
@@ -86,8 +89,9 @@ internal class FrameStats {
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
@Synchronized
fun graph(out: FloatArray) {
fun graph(out: FloatArray): Long {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
return graphFrames
}
@Synchronized
@@ -9,6 +9,7 @@ import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
@@ -33,6 +34,8 @@ object BottomScreenGuests {
else -> null
}
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
@@ -43,7 +46,7 @@ object BottomScreenGuests {
}
class BottomScreenWatch(private val service: AccessibilityService) {
private class Arrival(val pkg: String)
private class Arrival(val pkg: String, val at: Long)
private val thread = HandlerThread("bottom-screen").apply { start() }
private val handler = Handler(thread.looper)
@@ -52,6 +55,8 @@ class BottomScreenWatch(private val service: AccessibilityService) {
private var homes: Set<String> = emptySet()
private var keyboards: Set<String> = emptySet()
private var holders: Map<Int, String?> = emptyMap()
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
private val ownShownAt = mutableMapOf<Int, Long>()
private val check = Runnable { evaluate() }
fun start() {
@@ -72,7 +77,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val pkg = event.packageName?.toString() ?: return
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
arrivals[event.displayId] = Arrival(pkg)
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
}
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
else -> return
@@ -97,19 +102,25 @@ class BottomScreenWatch(private val service: AccessibilityService) {
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
val displays = service.getSystemService(DisplayManager::class.java) ?: return
val taken = mutableMapOf<Int, String?>()
val shown = mutableMapOf<Int, Set<Int>>()
val now = SystemClock.uptimeMillis()
for (i in 0 until all.size()) {
val id = all.keyAt(i)
if (id == Display.DEFAULT_DISPLAY) continue
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
val windows = all.valueAt(i)
val topPkg = windows.asSequence()
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
.sortedByDescending { it.layer }
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
.map { it to packageOf(it) }
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
shown[id] = ours
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
}
ownWindows = shown
if (current !== this) return
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
holders = taken
@@ -73,7 +73,7 @@ object ControllerPrefs {
keyboardButton = p.getBoolean("keyboardButton", true),
selectSteam = p.getBoolean("selectSteam", false),
joyConVr = joyConVr(context),
snap = p.getBoolean("snap", true),
snap = snap(context),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
deadZone = deadZone(context),
@@ -87,11 +87,6 @@ object ControllerPrefs {
prefs(context).edit().putString("map.$id", target).apply()
}
fun resetMapping(context: Context) {
val p = prefs(context)
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
}
fun setTint(context: Context, tint: Int) {
prefs(context).edit().putInt("tint", tint).apply()
}
@@ -129,10 +124,6 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("steamButton", on).apply()
}
fun setQamButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("qamButton", on).apply()
}
fun setKeyboardButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("keyboardButton", on).apply()
}
@@ -147,6 +138,8 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("joyConVr", on).apply()
}
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
fun setSnap(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("snap", on).apply()
}
@@ -172,9 +165,4 @@ object ControllerPrefs {
fun resetAll(context: Context) {
prefs(context).edit().clear().putBoolean("rumble", true).apply()
}
fun resetAllLayouts(context: Context) {
val p = prefs(context)
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
}
}
@@ -70,6 +70,14 @@ class DirectTouchHold(
handler.removeCallbacks(hold)
}
fun cancelTouch() {
handler.removeCallbacks(hold)
handler.removeCallbacks(lift)
liftPending = false
if (forwarded) touch.cancel()
forwarded = false
}
fun onTouch(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
if (liftPending) {
@@ -4,12 +4,13 @@ import android.content.Context
import android.hardware.display.DisplayManager
import android.util.DisplayMetrics
import android.view.Display
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
object InputLibraries {
private const val VERSION = 12
private const val VERSION = 14
private const val KEY = "library"
private const val RECOVERY = "library-recovery."
const val OVERLAY = "builtin.overlay"
@@ -30,6 +31,8 @@ object InputLibraries {
if (version < 11) library = neutralStockLayouts(library)
if (version < 10) library = restyleGamepads(context, library)
if (version < 12) library = barStockLayouts(library)
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
if (version < 14) library = deckGamepadSurfaces(context, library)
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
val legacy = LegacyScreenKeyMigration.snapshot(context)
if (legacy != null) {
@@ -263,6 +266,15 @@ object InputLibraries {
return addStockLayouts(result)
}
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
if (!leftPad && !rightStick) return library
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
}
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
@@ -292,7 +304,7 @@ object InputLibraries {
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel))
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
@@ -333,7 +345,7 @@ object InputLibraries {
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true))
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
}
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
@@ -45,6 +45,14 @@ internal object InputStockLayouts {
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
}
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
when {
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
else -> it
}
}
fun layouts() = listOf(
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
@@ -34,6 +34,7 @@ data class InputSurfaceElement(
val opacity: Float = 1f,
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
val showLabel: Boolean = true,
val alwaysShown: Boolean = false,
) {
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
@@ -11,6 +11,7 @@ class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
val directions get() = route?.action as? InputAction.Directions
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
fun pointerDelta(dx: Float, dy: Float) {
@@ -225,7 +225,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
)
}
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean): List<InputSurfaceElement> {
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
val placed = mutableListOf<InputSurfaceElement>()
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
@@ -243,9 +244,9 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
}
}
box(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
box(InputControl.RS, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
@@ -254,7 +255,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && stored.none { it.id == p.id } }
val surfaces = listOf(left, right).filter { it.isTrackpad }
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
}
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
@@ -40,7 +40,7 @@ internal object LegacyScreenKeyMigration {
if (result.profiles.any { it.id == id }) continue
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
if (converted.isEmpty()) continue
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN,
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
val base = library.profile(library.steam)
@@ -68,6 +68,17 @@ internal object LegacyScreenKeyMigration {
return result.validate()
}
fun pin(library: InputLibrary): InputLibrary {
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
return library.copy(layouts = library.layouts.map { layout ->
val imported = layout.id.startsWith("migrated.screenkeys.")
layout.copy(elements = layout.elements.map { e ->
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
if (key) e.copy(alwaysShown = true) else e
})
})
}
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
convert(context, parse(raw), width, height, density)
@@ -29,6 +29,9 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
private set
var isQuickHidden = false
private set
var isPinnedOnly = false
private set
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
var onTouchMiss: ((MotionEvent) -> Unit)?
get() = surface.onTouchMiss
set(value) { surface.onTouchMiss = value }
@@ -61,6 +64,13 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
render()
}
fun setPinnedOnly(enabled: Boolean) {
if (isPinnedOnly == enabled) return
releaseAll()
isPinnedOnly = enabled
render()
}
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
private fun placed(): List<InputSurfaceElement> {
@@ -72,12 +82,16 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
private fun render() {
val elements = placed()
surface.updateCustomLayout(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements)
surface.updateCustomLayout(when {
isPinnedOnly -> elements.filter { it.alwaysShown }
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
else -> elements
})
val style = layout?.style ?: InputSurfaceStyle()
surface.updateStyle(style)
quickToggle.tint = style.tint
quickToggle.alpha = style.opacity / 100f
quickToggle.visibility = if (isButtonsOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.invalidate()
}
@@ -7,7 +7,18 @@ import kotlin.math.max
import kotlin.math.min
object OnScreenLayout {
data class Dock(val portrait: Boolean, val grips: Boolean)
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
companion object {
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
}?.kind
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
return Dock(portrait, true, left, right)
}
}
}
private const val TOLERANCE = .01f
private const val KEYBOARD_ID = "builtin:keyboard"
@@ -17,13 +28,13 @@ object OnScreenLayout {
val stored = layout?.elements ?: return emptyList()
if (width <= 0 || height <= 0) return stored
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored)
val (reference, generated) = reference(context, layout, width, height)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe)
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
val placed = when {
gamepad.isEmpty() -> emptyList()
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips)
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
else -> generator.regenerate(gamepad)
}
val byId = (placed + others).associateBy { it.id }
@@ -52,4 +52,7 @@ object SecondScreenDisplays {
)
}
.toList()
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
(listOf(selected) + available).distinct().filterNot(taken)
}
@@ -213,6 +213,9 @@ data class TouchGestureConfig(
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
fun gamescopeRelativeMouse(mode: Int, mouseAttached: Boolean, touchpad: Boolean): Int =
if (mode == RELATIVE_MOUSE_AUTO && !mouseAttached && !touchpad) RELATIVE_MOUSE_OFF else mode
private fun normalizeRelativeMouseMode(mode: Int): Int {
return if (mode in RELATIVE_MOUSE_AUTO..RELATIVE_MOUSE_OFF) mode else RELATIVE_MOUSE_AUTO
}
@@ -15,6 +15,7 @@ object DesktopCatalog {
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.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"
const val APPIMAGE_EXEC = "/usr/local/bin/droiddeck-appimage-exec"
class Entry(
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
@@ -216,7 +217,7 @@ object DesktopCatalog {
target.setExecutable(true, false)
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Exec=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
@@ -21,9 +21,11 @@ object LegacyNames {
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
)
private const val MENU_DIR = "usr/local/share/applications"
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
private val MENU_REWRITES = listOf(
Triple("usr/local/share/applications", "/usr/local/bin/bannerlator-", "/usr/local/bin/droiddeck-"),
Triple("usr/share/applications", "Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/",
"Exec=${DesktopCatalog.APPIMAGE_EXEC} /opt/appimages/"),
)
@JvmStatic
@Synchronized
@@ -95,14 +97,16 @@ object LegacyNames {
}
private fun rewriteMenuEntries(root: File) {
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
for (entry in entries) {
val text = FileUtils.readString(entry) ?: continue
if (!text.contains(OLD_BIN)) continue
val staged = File(entry.parentFile, entry.name + ".staged")
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
staged.delete()
Log.w(TAG, "could not rewrite $entry")
for ((dir, old, new) in MENU_REWRITES) {
val entries = File(root, dir).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: continue
for (entry in entries) {
val text = FileUtils.readString(entry) ?: continue
if (!text.contains(old)) continue
val staged = File(entry.parentFile, entry.name + ".staged")
if (!FileUtils.writeString(staged, text.replace(old, new)) || !staged.renameTo(entry)) {
staged.delete()
Log.w(TAG, "could not rewrite $entry")
}
}
}
}
@@ -82,8 +82,12 @@ public final class LinuxRuntimeInstaller {
private LinuxRuntimeInstaller() {}
public static String installedVersion(Context context) {
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
}
private static String rootfsVersion(Context context) {
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
if (!marker.isFile()) return null;
String v = FileUtils.readString(marker);
return v == null ? null : v.trim();
}
@@ -144,6 +148,10 @@ public final class LinuxRuntimeInstaller {
}
};
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
if (packsOnly(app, release, progress)) {
job.ok = true;
return true;
}
job.ok = installOnce(app, release, progress);
if (job.ok && desktop) keepDesktop(app, progress);
return job.ok;
@@ -283,6 +291,31 @@ public final class LinuxRuntimeInstaller {
for (File file : files) file.delete();
}
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
return state -> {
if (listener != null && state instanceof AddonPack.State.Downloading) {
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
}
return kotlin.Unit.INSTANCE;
};
}
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
File root = LinuxRuntime.rootDir(context);
try {
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
} catch (IOException e) {
Log.w(TAG, "packs only: " + e);
return false;
}
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
&& LinuxRuntime.isInstalled(context);
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), archiveName(release));
boolean keepPartial = false;
@@ -294,10 +327,10 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
&& !SessionPrefs.allowStockProot(context))
throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
@@ -35,6 +35,7 @@ object UserApps {
private const val GITHUB_REPO = "github-repo"
private const val GITHUB_TAG = "github-tag"
private const val GITHUB_PUBLISHED = "github-published"
private const val GITHUB_ASSET = "github-asset"
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
@@ -56,7 +57,7 @@ object UserApps {
sealed class Source {
class Script(val file: File) : Source()
class AppImage(val file: File) : Source()
class GitHub(val repo: String) : Source()
class GitHub(val repo: String, val asset: String? = null) : Source()
class Flatpak(val id: String) : Source()
class FlatpakBundle(val file: File) : Source()
}
@@ -113,7 +114,7 @@ object UserApps {
when (val s = request.source) {
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.GitHub -> addFromGitHub(context, s.repo, s.asset, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
}
@@ -305,14 +306,26 @@ object UserApps {
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
}
internal fun pickAsset(names: List<String>): String? {
fun githubAsset(input: String): String? =
Regex("github\\.com/[\\w.-]+/[\\w.-]+/releases/(?:download/[^/]+|latest/download)/([^/?#]+\\.appimage)(?:[?#].*)?$", RegexOption.IGNORE_CASE)
.find(input.trim())?.groupValues?.get(1)?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
private fun assetVariant(name: String): Set<String> =
name.lowercase().replace(Regex("x86[_-]64|amd64"), "x64").replace(Regex("x86[_-]32|i[36]86"), "x32")
.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() && !Regex("[0-9a-f]{7,}|v?[0-9]+").matches(it) }.toSet()
internal fun pickAsset(names: List<String>, wanted: String? = null): String? {
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
if (wanted != null) {
images.firstOrNull { it == wanted }?.let { return it }
images.firstOrNull { assetVariant(it) == assetVariant(wanted) }?.let { return it }
}
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
}
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
private fun latestRelease(context: Context, repo: String, wanted: String? = null): Pair<Release?, String?> {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
@@ -321,7 +334,7 @@ object UserApps {
if (r.optBoolean("draft")) return@mapNotNull null
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } }
pickAsset(byName.keys.toList())?.let { asset ->
pickAsset(byName.keys.toList(), wanted)?.let { asset ->
r.optBoolean("prerelease") to byName.getValue(asset).let { a ->
Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"),
Hashes.githubSha256(a.optString("digest")))
@@ -344,11 +357,11 @@ object UserApps {
}
private fun githubMeta(repo: String, release: Release) =
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published, GITHUB_ASSET to release.asset)
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
val (release, problem) = latestRelease(context, repo)
val (release, problem) = latestRelease(context, repo, asset)
if (release == null) return problem
return withDownload(context, release, onProgress) { file ->
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
@@ -381,9 +394,10 @@ object UserApps {
fun checkUpdate(context: Context, app: App): UpdateCheck {
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
val (release, problem) = latestRelease(context, repo)
val dir = AppImageManager.dir(context, app.key.substringAfter(':'))
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
}
@@ -1,60 +0,0 @@
package com.droiddeck.launcher.session
import android.app.Notification
import android.content.Context
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.compose.runtime.mutableStateListOf
import androidx.core.app.NotificationManagerCompat
data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
object BottomNotifications {
val items = mutableStateListOf<BottomNotification>()
fun granted(context: Context): Boolean =
context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean =
pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank())
internal fun add(list: MutableList<BottomNotification>, item: BottomNotification) {
list.removeAll { it.key == item.key }
list.add(0, item)
while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex)
}
fun clear() = items.clear()
internal fun post(context: Context, sbn: StatusBarNotification) {
val n = sbn.notification ?: return
val extras = n.extras
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return
val app = runCatching {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString()
}.getOrDefault(sbn.packageName)
add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime))
}
internal fun remove(key: String) {
items.removeAll { it.key == key }
}
private const val MAX_ITEMS = 30
}
class BottomNotificationListener : NotificationListenerService() {
override fun onListenerConnected() {
BottomNotifications.clear()
runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) }
}
override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn)
override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key)
override fun onListenerDisconnected() = BottomNotifications.clear()
}
@@ -68,8 +68,9 @@ object GameStorage {
externalStoragePath: Boolean = isExternalStoragePath(path),
): Boolean = when {
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
path.isDirectory -> true
externalStoragePath -> false
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
else -> false
}
@@ -48,11 +48,19 @@ object ProtonExtras {
internal fun installedIn(dir: File, tool: Tool): List<String> =
dir.listFiles()
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile }
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && usable(it) }
?.map { it.name }
?.sortedWith { a, b -> compareVersions(a, b) }
?: emptyList()
private fun usable(dir: File): Boolean {
val manifest = runCatching { File(dir, "toolmanifest.vdf").readText() }.getOrNull() ?: return false
val command = Regex("\\\"commandline\\\"\\s+\\\"/?([^\\\" ]+)").find(manifest)?.groupValues?.getOrNull(1) ?: return false
val compat = runCatching { File(dir, "compatibilitytool.vdf").readText() }.getOrNull() ?: return false
return Regex("\\\"from_oslist\\\"\\s+\\\"[^\\\"]*windows", RegexOption.IGNORE_CASE).containsMatchIn(compat) &&
File(dir, command).isFile && File(dir, command).canExecute()
}
private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64")
private val NUMBER = Regex("\\d+")
@@ -69,8 +69,8 @@ object SessionDisplay {
const val UNFOLDED_MIN_DP = 600
fun unfoldedGameHeight(width: Int, height: Int): Int =
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
fun unfoldedGameHeight(width: Int, height: Int, tabRow: Int = 0): Int =
(minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) - tabRow.coerceAtLeast(0)).coerceAtLeast(0) and 1.inv()
fun unfoldedControlsWanted(
enabled: Boolean,
@@ -14,6 +14,7 @@ object SessionEvents {
SessionPaths.take()
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
synchronized(SessionState) {
SessionState.clearRelaunch()
SessionState.phase = SessionPhase.PREPARING
SessionState.mode = mode
SessionState.sessionId = dir.name
@@ -40,8 +40,10 @@ object SessionFiles {
WinComponents.publish(context)
val files = arrayOf(
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
"usr/local/bin/droiddeck-wine-pe" to "usr/local/bin/droiddeck-wine-pe",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
@@ -112,6 +114,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
"usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run",
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
"usr/local/bin/droiddeck-appimage-exec" to "usr/local/bin/droiddeck-appimage-exec",
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
@@ -0,0 +1,8 @@
package com.droiddeck.launcher.session
internal object SessionLoadingDecision {
const val TIMEOUT_MS = 10_000L
fun shouldReturnToLauncher(startedAt: Long, now: Long, running: Boolean, phase: SessionPhase): Boolean =
!running && phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED) && now - startedAt >= TIMEOUT_MS
}
@@ -558,11 +558,11 @@ object SessionPrefs {
fun picturePosition(context: Context): String =
prefs(context).getString("picturePosition", "center")
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
?.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center"
fun setPicturePosition(context: Context, position: String) {
prefs(context).edit().putString("picturePosition",
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply()
}
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
@@ -1555,10 +1555,6 @@ object SessionPrefs {
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 deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
@@ -1697,8 +1697,8 @@ class SessionService : Service() {
.putExtra(EXTRA_STEAM_UI, ui)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url)
SessionState.relaunch = next
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) }
SessionState.queueRelaunch(next)
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) }
}
}
watcher.startWatching()
@@ -2013,7 +2013,7 @@ class SessionService : Service() {
}
}
private fun stopSession(status: Int, gen: Int? = null) {
private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) {
if (gen != null && gen != sessionGen) {
Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current")
return
@@ -2024,6 +2024,7 @@ class SessionService : Service() {
cancelQueuedSteamGame()
gameExitWatchToken++
if (!SessionState.running) return
if (relaunch) SessionState.confirmRelaunchStop()
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
SessionState.running = false
@@ -73,7 +73,44 @@ object SessionState {
@Volatile
var steamClientArch: String? = null
var steamVr by mutableStateOf(false)
@Volatile
var relaunch: android.content.Intent? = null
@Volatile
private var relaunchSessionId: String? = null
@Volatile
private var relaunchStopRequested = false
fun queueRelaunch(intent: android.content.Intent) {
synchronized(this) {
relaunch = intent
relaunchSessionId = sessionId
relaunchStopRequested = false
}
}
fun confirmRelaunchStop() {
synchronized(this) {
relaunchStopRequested = relaunch != null && relaunchSessionId == sessionId
}
}
fun takeRelaunch(): android.content.Intent? = synchronized(this) {
val next = relaunch
val valid = relaunchStopRequested && relaunchSessionId == sessionId
relaunch = null
relaunchSessionId = null
relaunchStopRequested = false
next.takeIf { valid }
}
fun clearRelaunch() {
synchronized(this) {
relaunch = null
relaunchSessionId = null
relaunchStopRequested = false
}
}
@JvmStatic var hdr = false
@Volatile private var deckPadValue = false
@@ -65,6 +65,8 @@ private fun releaseDateValue(raw: String): Long =
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
} ?: 0L
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
fun <T> sortGames(
items: List<T>,
sort: String,
@@ -204,7 +204,8 @@ object BattleNetBackend : StoreBackend {
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
val art = BattleNet.art(context, game.title)
val cover = art.cover?.let { Uri.fromFile(it).toString() }
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
}
private fun shelves(context: Context): StoreShelves {
@@ -64,6 +64,8 @@ object GogBackend : StoreBackend {
}, "gog-sync").start()
}
override fun search(context: Context, query: String): List<CatalogItem> = GogStoreCatalog.search(query)
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
@@ -16,6 +16,7 @@ internal object GogContent {
fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry")
@JvmStatic
fun builds(id: String, token: String): JSONObject {
val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token)
if (result.optJSONArray("items")?.length() != 0) return result
@@ -24,6 +25,36 @@ internal object GogContent {
fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token)
@JvmStatic
fun ownedProductIds(token: String): Set<String>? = runCatching {
val body = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token) ?: return@runCatching null
JSONObject(body).optJSONArray("owned")?.strings()?.toSet() ?: emptySet()
}.getOrNull()
@JvmStatic
fun depotProducts(depot: JSONObject, baseProductId: String): List<String> =
depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId").ifEmpty { baseProductId })
@JvmStatic
fun clientCredentials(manifest: JSONObject): Pair<String, String>? {
val clientId = manifest.optString("clientId")
val clientSecret = manifest.optString("clientSecret")
return clientId.takeIf { it.isNotBlank() }?.let { id -> clientSecret.takeIf { it.isNotBlank() }?.let { id to it } }
}
@JvmStatic
fun tempExecutable(manifest: JSONObject): String? = manifest.optJSONArray("products")?.optJSONObject(0)
?.optString("temp_executable")?.takeIf { it.isNotBlank() }
@JvmStatic
fun installProducts(baseProductId: String, depots: JSONArray, installed: List<String>, token: String,
ownership: (String) -> Set<String>? = { ownedProductIds(it) }): Set<String> {
val listed = depots.objects().flatMap { depotProducts(it, baseProductId) }.distinct()
val owned = if (listed.any { it != baseProductId } || installed.any { it != baseProductId }) ownership(token) else emptySet()
check(installed.all { it == baseProductId || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
return (listOf(baseProductId) + installed + listed.filter { it == baseProductId || owned?.contains(it) == true }).toSet()
}
fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8")
fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part ->
@@ -42,6 +73,7 @@ internal object GogContent {
} finally { connection.disconnect() }
})
@JvmStatic
fun json(url: String, token: String? = null): JSONObject {
require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" }
val connection = URL(url).openConnection() as HttpURLConnection
@@ -61,21 +93,21 @@ internal object GogContent {
}
fun snapshot(old: GogInstallRecord, build: GogBuild, token: String,
metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord {
ownership: (String) -> Set<String>? = { ownedProductIds(it) },
metadata: (String, String?) -> JSONObject = ::json,
onManifest: (JSONObject) -> Unit = {}): GogInstallRecord {
check(build.link.isNotBlank()) { "GOG build manifest is unavailable" }
val manifest = metadata(build.link, token)
onManifest(manifest)
val files = JSONArray()
val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots")
check(depots != null) { "GOG build has no depot list" }
val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language)
val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32"
val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet()
val products = old.products.filter { it == old.id || owned?.contains(it) == true }
check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
val products = installProducts(old.id, depots, old.products, token, ownership)
for (depot in depots.objects()) {
if (depot.has("redist")) continue
val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id))
val product = ids.firstOrNull { it in products } ?: continue
val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue
val languages = depot.optJSONArray("languages")?.strings().orEmpty()
if (!languageSelected(languages, resolvedLanguage)) continue
val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty()
@@ -108,7 +140,7 @@ internal object GogContent {
}
check(files.length() > 0) { "GOG build contains no matching files" }
return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version,
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true)
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products.toList(), verified = true)
}
fun languageSelected(list: List<String>, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected)
@@ -125,211 +125,22 @@ public final class GogDownloadManager {
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
Gen2Outcome out = new Gen2Outcome();
try {
cb.onProgress("Fetching manifest…", 5);
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb);
if (gm == null) { out.error = "gen2 manifest unavailable"; return out; }
JSONObject manifest = gm.manifest;
JSONArray depots = manifest.optJSONArray("depots");
if (depots == null) { out.error = "no depots in manifest"; return out; }
JSONArray products = manifest.optJSONArray("products");
String tempExe = null;
if (products != null && products.length() > 0) {
tempExe = products.getJSONObject(0).optString("temp_executable", null);
if (tempExe != null && tempExe.isEmpty()) tempExe = null;
}
String baseProductId = manifest.optString("baseProductId", null);
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;
JSONArray deps = manifest.optJSONArray("dependencies");
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<>();
List<String> depotJsons = new ArrayList<>();
JSONArray owned = new JSONArray();
List<JSONObject> ownedDepots = new ArrayList<>();
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
String depotPid = depot.optString("productId", "");
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
owned.put(depot);
ownedDepots.add(depot);
}
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
cb.onLog("gog: language " + resolved);
for (int i = 0; i < ownedDepots.size(); i++) {
if (cancelled.get()) return out;
JSONObject depot = ownedDepots.get(i);
if (!depotSelected(depot, resolved)) continue;
String manifestHash = depot.optString("manifest");
if (manifestHash == null || manifestHash.isEmpty()) continue;
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; }
String dmStr = decompressBytes(dmRaw);
if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; }
parseDepotManifest(dmStr, files);
depotJsons.add(dmStr);
}
if (files.isEmpty()) { out.error = "no depot files collected"; return out; }
cb.onProgress("Fetching CDN link…", 15);
String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/";
String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token));
if (cdnBase == null) { out.error = "secure link refused"; return out; }
installPath.mkdirs();
long plannedBytes = 0;
for (DepotFile df : files) plannedBytes += df.totalSize;
long remainingBytes = remainingInstallBytes(files, installPath);
if (remainingBytes > 0) {
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath);
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) {
out.hardStop = true;
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
return out;
}
}
Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize));
final int total = files.size();
final long planned = plannedBytes;
final AtomicInteger doneCount = new AtomicInteger(0);
final AtomicLong totalBytes = new AtomicLong(0);
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
final AtomicLong lastSpeedB = new AtomicLong(0);
final AtomicLong speedBps = new AtomicLong(0);
final AtomicBoolean anyFailed = new AtomicBoolean(false);
final AtomicReference<String> cdnBaseRef = new AtomicReference<>(cdnBase);
final AtomicInteger cdnRefreshCount = new AtomicInteger(0);
final int MAX_CDN_REFRESH = 5;
cb.onSizes(planned, planned);
cb.onBytes(0, planned, 0);
cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId);
int threads = downloadThreads();
if (GogNative.isAvailable()) {
runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token,
cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId,
cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
cb.onLog("gog: engine=built-in (" + threads + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
List<Future<Void>> futures = new ArrayList<>();
for (DepotFile df : files) {
futures.add(pool.submit((Callable<Void>) () -> {
if (cancelled.get() || anyFailed.get()) return null;
File outFile = new File(installPath, df.relativePath);
outFile.getParentFile().mkdirs();
if (fileVerified(outFile, df.totalSize, df.md5)) {
int done = doneCount.incrementAndGet();
long tb = totalBytes.addAndGet(df.totalSize);
cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80));
cb.onBytes(tb, planned, speedBps.get());
return null;
}
boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb);
if (cancelled.get()) return null;
if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; }
int done = doneCount.incrementAndGet();
long tb = totalBytes.addAndGet(df.totalSize);
sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80));
cb.onBytes(tb, planned, speedBps.get());
return null;
}));
}
pool.shutdown();
try {
for (Future<Void> f : futures) f.get();
} catch (Exception e) {
pool.shutdownNow();
out.error = "download pool error: " + e.getClass().getSimpleName();
return out;
}
}
if (cancelled.get()) return out;
if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; }
cb.onProgress("Finishing…", 96);
try {
GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved);
} catch (Exception e) {
cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage());
}
writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8"));
deleteCounted(new File(installPath, ".gog_chunks"), cb);
String clientId = manifest.optString("clientId", null);
if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply();
String clientSecret = manifest.optString("clientSecret", null);
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
GameTask task = pickTask(installPath, game.gameId, tempExe, game.title);
out.result = new Result(task == null ? "" : task.path, gm.buildId, planned);
if (task != null) out.result.workDir = task.workDir;
out.result.dependencies = dependencies;
out.result.dependenciesRead = true;
out.result.language = resolved;
return out;
out.result = GogMaintenance.installContent(ctx, game, installPath, new JSONObject(buildsJson), language, cb, cancelled);
if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build";
} catch (Exception e) {
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
return out;
}
}
private static final class Gen2Manifest {
final JSONObject manifest; final String buildId;
Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; }
}
private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) {
try {
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; }
String buildId = null, manifestUrl = null;
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.getJSONObject(i);
if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) {
buildId = item.optString("build_id");
manifestUrl = item.optString("link");
if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
break;
}
}
if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; }
byte[] raw = fetchBytes(manifestUrl, token);
if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; }
String str = decompressBytes(raw);
if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; }
return new Gen2Manifest(new JSONObject(str), buildId);
} catch (Exception e) {
cb.onLog("gog: manifest " + e.getClass().getSimpleName());
return null;
}
}
private static boolean depotSelected(JSONObject depot, String resolved) {
JSONArray languages = depot.optJSONArray("languages");
if (languages == null || languages.length() == 0) return true;
String[] listed = new String[languages.length()];
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
return StoreLanguage.depotMatchesResolved(listed, resolved);
return out;
}
public static List<String> listLanguages(Context ctx, String gameId) {
try {
String token = GogAuth.validToken(ctx);
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 Collections.emptyList();
Callback silent = new Callback() {
@Override public void onProgress(String message, int pct) {}
};
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
if (gm == null) return Collections.emptyList();
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
JSONObject builds = GogContent.builds(gameId, token);
GogBuild build = GogBuild.Companion.select(builds, "", "");
if (build == null || build.getGeneration() != 2) return Collections.emptyList();
return StoreLanguage.codesFromDepots(GogContent.json(build.getLink(), token).optJSONArray("depots"));
} catch (Exception e) {
return Collections.emptyList();
}
@@ -398,7 +209,7 @@ public final class GogDownloadManager {
int threads = downloadThreads();
if (GogNative.isAvailable()) {
runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
threads, threads, false, "gog gen1=" + game.gameId,
cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
@@ -659,8 +470,7 @@ public final class GogDownloadManager {
private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List<String> depotJsons, File installPath,
AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers,
int maxWorkers, int processWorkers,
boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed,
AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes,
AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) {
@@ -673,10 +483,8 @@ public final class GogDownloadManager {
int run = 0;
while (!cancelled.get()) {
run++;
final String base = cdnBaseRef.get();
final CountDownLatch latch = new CountDownLatch(1);
final AtomicBoolean okRef = new AtomicBoolean(false);
final AtomicBoolean linkExpiryRef = new AtomicBoolean(false);
final AtomicReference<String> errRef = new AtomicReference<>("");
final long runStart = totalBytes.get();
final AtomicLong verifiedInRun = new AtomicLong(0);
@@ -702,12 +510,10 @@ public final class GogDownloadManager {
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {}
@Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) {
okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
okRef.set(success); errRef.set(error == null ? "" : error); latch.countDown();
}
};
String[] bases = (base == null || base.isEmpty()) ? new String[0] : base.split("\n");
if (run == 1) cb.onLog("gog: " + bases.length + " CDN host(s) offered");
long handle = GogNative.start(kind, manifests, bases, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
workers, process, sortLargestFirst, label + " run=" + run, listener);
if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
boolean interrupted = false;
@@ -721,85 +527,13 @@ public final class GogDownloadManager {
}
if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
if (cancelled.get() || okRef.get()) return;
String err = errRef.get();
if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) {
cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files");
continue;
}
cb.onLog("gog: engine failed: " + err);
cb.onLog("gog: engine failed: " + errRef.get());
anyFailed.set(true);
return;
}
}
private static void parseDepotManifest(String json, List<DepotFile> out) {
try {
JSONObject depotObj = new JSONObject(json).optJSONObject("depot");
if (depotObj == null) return;
JSONArray depot = depotObj.optJSONArray("items");
if (depot == null) return;
for (int i = 0; i < depot.length(); i++) {
JSONObject entry = depot.getJSONObject(i);
String path = entry.optString("path", "").replace("\\", "/");
if (path.startsWith("/")) path = path.substring(1);
JSONArray chunks = entry.optJSONArray("chunks");
if (path.isEmpty() || chunks == null || chunks.length() == 0) continue;
DepotFile df = new DepotFile(path);
String fileMd5 = entry.optString("md5", null);
if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5;
long fileTotal = 0;
for (int c = 0; c < chunks.length(); c++) {
JSONObject chunk = chunks.getJSONObject(c);
String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5");
String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5;
if (hash == null || hash.isEmpty()) continue;
long decSize = chunk.optLong("size", 0);
df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize));
fileTotal += decSize;
}
df.totalSize = fileTotal;
if (!df.chunks.isEmpty()) out.add(df);
}
} catch (Exception e) {
Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName());
}
}
static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; }
private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp");
File parent = outFile.getParentFile();
if (parent != null) parent.mkdirs();
tmpFile.delete();
boolean ok = true;
try (FileOutputStream fos = new FileOutputStream(tmpFile)) {
for (DepotFile.ChunkRef chunk : df.chunks) {
if (cancelled.get()) { ok = false; break; }
byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb);
if (inflated == null) { ok = false; break; }
fos.write(inflated);
}
} catch (Exception e) {
ok = false;
}
if (!ok || cancelled.get()) { tmpFile.delete(); return false; }
if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
if (df.md5 != null && !df.md5.isEmpty()) {
String actual = md5HexFile(tmpFile);
if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
}
if (outFile.exists()) outFile.delete();
return tmpFile.renameTo(outFile);
}
private static String parseCdnUrl(String json) {
java.util.List<String> all = parseCdnUrls(json);
return all.isEmpty() ? null : String.join("\n", all);
}
static java.util.List<String> parseCdnUrls(String json) {
java.util.List<String> out = new ArrayList<>();
if (json == null) return out;
@@ -824,18 +558,6 @@ public final class GogDownloadManager {
return out;
}
private static final AtomicInteger CDN_TURN = new AtomicInteger();
static String pickCdn(String set) {
String[] all = set.split("\n");
return all[Math.floorMod(CDN_TURN.getAndIncrement(), all.length)];
}
static String firstCdn(String set) {
int nl = set.indexOf('\n');
return nl < 0 ? set : set.substring(0, nl);
}
private static boolean downloadRange(String url, long offset, long size, File out) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
@@ -951,15 +673,6 @@ public final class GogDownloadManager {
}
}
private static String buildChunkUrl(String base, String chunkPath) {
int q = base.indexOf('?');
return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath;
}
private static String md5Hex(byte[] data) {
try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; }
}
private static String md5HexFile(File f) {
try (FileInputStream fis = new FileInputStream(f)) {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
@@ -988,58 +701,6 @@ public final class GogDownloadManager {
return true;
}
private static void sleepBackoff(int attempt) {
try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
private static boolean tryRefreshCdn(AtomicReference<String> cdnBaseRef, String staleBase, String secureLinkUrl, String token,
AtomicInteger refreshCount, int cap, Callback cb) {
if (secureLinkUrl == null) return false;
synchronized (cdnBaseRef) {
String current = cdnBaseRef.get();
if (staleBase == null || !staleBase.equals(current)) return true;
if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; }
String neu = parseCdnUrl(httpGet(secureLinkUrl, token));
if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; }
cdnBaseRef.set(neu);
cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet());
return true;
}
}
private static byte[] fetchChunkVerified(AtomicReference<String> cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
int hardFail = 0, guard = 0;
while (guard++ < 8) {
if (cancelled.get()) return null;
String base = cdnBaseRef.get();
HttpResult res = fetchBytesEx(buildChunkUrl(pickCdn(base), chunkPath), null);
if (res.body == null) {
int code = res.status;
if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue;
cb.onLog("chunk http fail code=" + code);
if (++hardFail >= 3) return null;
sleepBackoff(hardFail);
continue;
}
byte[] raw = res.body;
if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) {
String actual = md5Hex(raw);
if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
}
byte[] inflated = inflateZlib(raw);
if (inflated == null) inflated = raw;
if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
if (chunk.md5 != null && !chunk.md5.isEmpty()) {
String actual = md5Hex(inflated);
if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
}
return inflated;
}
return null;
}
private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) {
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
@@ -1289,30 +950,6 @@ public final class GogDownloadManager {
for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
}
static long remainingInstallBytes(List<DepotFile> files, File installPath) {
long remaining = 0;
for (DepotFile file : files) {
File output = new File(installPath, file.relativePath);
if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize;
}
return remaining;
}
static final class DepotFile {
final String relativePath;
final List<ChunkRef> chunks = new ArrayList<>();
String md5;
long totalSize;
DepotFile(String relativePath) { this.relativePath = relativePath; }
static final class ChunkRef {
final String hash, compressedMd5, md5;
final long compressedSize, size;
ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) {
this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size;
}
}
}
private static final class Gen1File {
final String path, url; final long offset, size;
Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; }
@@ -1382,7 +1019,11 @@ public final class GogDownloadManager {
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
JSONArray languages = depot.optJSONArray("languages");
String[] listed = new String[languages == null ? 0 : languages.length()];
for (int j = 0; j < listed.length; j++) listed[j] = languages.optString(j, "");
if (listed.length == 0 || StoreLanguage.depotMatchesResolved(listed, resolved))
total += depot.optLong("size", 0);
}
return total > 0 ? total : -1;
} catch (Exception e) {
@@ -58,16 +58,17 @@ object GogLibrary {
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
onStatus(app.getString(R.string.stores_sync_game_list))
val listing = ownedListing(token)
val account = ownedIds(token)
val listed: List<GogGame> = listing?.games
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
val named = listing != null
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
val cachedList = cached(app)
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val ownedSet = ownedSet(listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }, account, cachedIds)
StoresState.logLine(if (named) "GOG: listing ${listed.size} games, ${ownedSet.size} owned ids" else "GOG: listing unreadable, ${listed.size} owned ids")
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
@@ -133,6 +134,9 @@ object GogLibrary {
return all.ifEmpty { null }?.let { Listing(it, owned) }
}
fun ownedSet(listed: Set<String>, account: List<String>?, cached: Set<String> = emptySet()): Set<String> =
if (account == null) listed + cached else listed + account
private fun ownedIds(token: String): List<String>? {
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
@@ -377,16 +377,24 @@ internal object GogMaintenance {
val build = GogBuild.select(builds) ?: return null
folder.mkdirs()
val old = GogInstallRecord(game.gameId, "depot", language = language)
val target = GogContent.snapshot(old, build, GogContent.token(context))
var credentials: Pair<String, String>? = null
val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest ->
credentials = GogContent.clientCredentials(manifest)
})
GogFiles.validate(target.files, folder)
val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") }
val disk = target.files.objects().sumOf { it.optLong("size") }
check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = GogNative.isAvailable()).required
check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
callback.onSizes(bytes, disk)
transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback)
if (cancelled.get()) return null
target.write(folder)
val task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title)
credentials?.let { (clientId, clientSecret) ->
GogPrefs.get(context).edit().putString("client_id_${game.gameId}", clientId)
.putString("client_secret_${game.gameId}", clientSecret).apply()
}
val task = GogDownloadManager.pickTask(folder, game.gameId, GogContent.tempExecutable(target.manifest), game.title)
val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk)
result.workDir = task?.workDir
result.language = target.language
@@ -395,20 +403,6 @@ internal object GogMaintenance {
return result
}
@JvmStatic
fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) {
val selected = GogBuild.select(JSONObject(buildsJson), pinned = build)
val files = JSONArray()
for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) {
val chunks = item.optJSONArray("chunks") ?: continue
files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") })
.put("md5", item.optString("md5")).put("chunks", chunks).put("product", id))
}
GogFiles.validate(files, folder)
GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language,
manifest = GogContent.sanitize(manifest), files = files).write(folder)
}
@JvmStatic
fun captureOffline(id: String, folder: File, downloads: JSONObject) {
val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return
@@ -6,8 +6,6 @@ import com.droiddeck.launcher.stores.StoresNative
object GogNative {
private const val TAG = "GogNative"
const val KIND_GEN2_CHUNKS = 0
const val KIND_GEN1_RANGES = 1
interface Listener {
@@ -151,7 +151,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) }
?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
@@ -1,116 +0,0 @@
package com.droiddeck.launcher.ui
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.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.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.BottomNotifications
import kotlinx.coroutines.delay
internal fun notificationAccessIntent(context: android.content.Context): Intent =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
android.content.ComponentName(context, com.droiddeck.launcher.session.BottomNotificationListener::class.java).flattenToString(),
)
} else Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
@Composable
fun BottomNotificationsPage(openSettings: ((Intent, () -> Boolean) -> Unit)?) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var granted by remember { mutableStateOf(BottomNotifications.granted(context)) }
LaunchedEffect(Unit) {
while (true) {
granted = BottomNotifications.granted(context)
delay(2_000L)
}
}
if (!granted) {
Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) {
Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center)
BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") {
val intent = notificationAccessIntent(context)
if (openSettings != null) openSettings(intent) { BottomNotifications.granted(context) }
else runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
return
}
val shown = BottomNotifications.items
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
}
return
}
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.bottom_notifications_count, shown.size), fontSize = 13.sp, color = colors.onSurfaceVariant,
modifier = Modifier.weight(1f))
BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() }
}
LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(shown.toList(), key = { it.key }) { n ->
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
}
if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -1,17 +1,31 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
class BottomPageActions(
val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null,
val fps: () -> Float? = { null },
val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null,
val runningGame: () -> String? = { null },
)
@@ -20,7 +34,18 @@ internal const val BOTTOM_PAGE_TELEMETRY = "telemetry"
fun sessionBottomPages(actions: BottomPageActions): List<BottomPage> = listOf(
BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) },
BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) },
BottomPage("chat", R.string.bottom_page_chat, Icons.Outlined.Forum) { BottomChatPage() },
)
@Composable
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
val now = System.nanoTime()
val live = frames.live
val stats = frames.stats
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
val graph = FloatArray(FrameStats.GRAPH_FRAMES)
val graphEnd = if (live) stats.graph(graph) else 0L
val hz = (frames.refreshMilliHz + 499) / 1000
val data = HudData(
fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY,
@@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock,
fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(),
historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()),
graphEnd = graphEnd,
coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit,
tempColor = { sensor, c ->
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
@@ -16,7 +16,7 @@ import com.droiddeck.launcher.input.*
internal fun InputActionEditor(
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
properties: (@Composable ColumnScope.() -> Unit)? = null,
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
click: Boolean = false,
) {
val context = LocalContext.current
@@ -46,7 +46,7 @@ internal fun InputActionEditor(
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
properties?.invoke(this)
properties?.invoke(this, draft)
Spacer(Modifier.height(12.dp))
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
when (it) {
@@ -58,12 +58,17 @@ internal fun InputDisplayContent(host: ComponentActivity, display: Display, onDi
dismiss()
}
}
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowShown(display.displayId)
showPresentation()
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
if (event == Lifecycle.Event.ON_START) showPresentation()
}
host.lifecycle.addObserver(observer)
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
onDispose {
host.lifecycle.removeObserver(observer)
presentation.dismiss()
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowClosed(display.displayId)
}
}
}
@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
internal fun InputElementProperties(element: InputSurfaceElement, onChange: (InputSurfaceElement) -> Unit) {
internal fun InputElementProperties(element: InputSurfaceElement, action: InputAction? = null, onChange: (InputSurfaceElement) -> Unit) {
val current by rememberUpdatedState(element)
val change by rememberUpdatedState(onChange)
OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) },
@@ -72,7 +72,9 @@ internal fun InputElementProperties(element: InputSurfaceElement, onChange: (Inp
}
val host = rememberMenuHost()
if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() ||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:"))
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:") ||
(element.kind == InputElementKind.STICK || element.kind == InputElementKind.DPAD) && (action as? InputAction.Directions)
?.let { listOf(it.up, it.right, it.down, it.left).plus(if (element.kind == InputElementKind.STICK) it.diagonals.orEmpty() else emptyList()).any { code -> code != 0 } } == true)
ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) }
SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5,
format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) }
@@ -169,7 +169,7 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe
?: b.controls[editing.control] ?: InputAction.Native()
InputActionEditor(editing.control, current, if (editing.click) stringResource(R.string.input_click_name, editing.element!!.name)
else editing.element?.name ?: editing.key?.label ?: editing.control.label,
properties = { if (!editing.click) elementProperties?.let { e -> InputElementProperties(e) { elementProperties = it } } },
properties = { action -> if (!editing.click) elementProperties?.let { e -> InputElementProperties(e, action) { elementProperties = it } } },
onBack = { edit = null }, element = editing.element, nativeTargets = editing.key == null, click = editing.click,
detail = if (library.usersOfBindings(b.id).size > 1) stringResource(R.string.input_shared_detail, b.name, library.usersOfBindings(b.id).size)
else "${profile.name} · ${roleLabel(role)}", onSave = { action ->
@@ -69,7 +69,7 @@ private class GamepadScaler(private val context: android.content.Context, privat
}
private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List<InputSurfaceElement>? = null,
private val gamepad: GamepadScaler? = null) {
private val gamepad: GamepadScaler? = null, snap: Boolean = true) {
var elements by mutableStateOf(layout.elements)
var actions by mutableStateOf(preset.elements)
var clicks by mutableStateOf(preset.clicks)
@@ -77,7 +77,7 @@ private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputB
var toolsExpanded by mutableStateOf(false)
var toolsX by mutableFloatStateOf(.02f)
var toolsY by mutableFloatStateOf(.02f)
var snap by mutableStateOf(true)
var snap by mutableStateOf(snap)
var adding by mutableStateOf(false)
var properties by mutableStateOf(false)
var clickProperties by mutableStateOf(false)
@@ -132,7 +132,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels)
GamepadScaler(display, w, h)
}
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad)
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad, ControllerPrefs.snap(context))
}
val manager = remember { context.getSystemService(DisplayManager::class.java) }
var revision by remember { mutableIntStateOf(0) }
@@ -162,7 +162,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val listener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(id: Int) { revision++ }
override fun onDisplayRemoved(id: Int) { revision++ }
override fun onDisplayChanged(id: Int) { revision++ }
override fun onDisplayChanged(id: Int) = Unit
}
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
onDispose { manager.unregisterDisplayListener(listener) }
@@ -171,13 +171,18 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val presentation = SurfaceEditorPresentation(host, target, transparent = host is SessionActivity && role == InputRole.PRIMARY) {
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
}
(host as? SessionActivity)?.inputWindowShown(target.displayId)
runCatching { presentation.show() }.onFailure { failure = it.message }
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
if (event == Lifecycle.Event.ON_START) runCatching { presentation.show() }
}
host.lifecycle.addObserver(observer)
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
onDispose {
host.lifecycle.removeObserver(observer)
presentation.dismiss()
(host as? SessionActivity)?.inputWindowClosed(target.displayId)
}
}
// Compose 1.6 custom dialog sizing subtracts system bars even when they are hidden.
Dialog(onDismissRequest = ::leave, properties = DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false)) {
@@ -198,8 +203,8 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
if (draft.clickProperties) draft.clickAction(element) else draft.action(element),
if (draft.clickProperties) stringResource(R.string.input_click_name, element.name) else element.name,
onBack = { draft.properties = false }, element = element, click = draft.clickProperties,
properties = {
if (!draft.clickProperties) InputElementProperties(properties) { properties = it }
properties = { action ->
if (!draft.clickProperties) InputElementProperties(properties, action) { properties = it }
if (layoutUsers > 1) Text(stringResource(R.string.input_editor_shared, layoutUsers),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp))
@@ -309,13 +314,14 @@ private fun FloatingSurfaceTools(draft: SurfaceDraft, onSave: () -> Unit, onLeav
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
val context = LocalContext.current
FlowRow(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave)
SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true }
SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() }
if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(draft.snap, { draft.snap = it })
Checkbox(draft.snap, { draft.snap = it; ControllerPrefs.setSnap(context, it) })
Text(stringResource(R.string.ctrl_editor_snap))
}
PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave)
@@ -32,10 +32,12 @@ import com.droiddeck.launcher.input.InputSurfaceStyle
import com.droiddeck.launcher.input.pointerSubset
import com.droiddeck.launcher.input.InputSurfaceSource
import com.droiddeck.launcher.input.InputElementKind
import com.droiddeck.launcher.input.InputAction
import com.droiddeck.launcher.input.InputButtonIcons
import com.droiddeck.launcher.input.InputButtonShape
import com.droiddeck.launcher.input.InputButtonImages
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.pcKeyLabel
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.atan2
import kotlin.math.hypot
@@ -195,7 +197,7 @@ class InputSurfaceView(
canvas.addView(when (element.kind) {
InputElementKind.STICK -> StickView(context, element, source, element.floating, element.hideWhenIdle && pad != null)
InputElementKind.TRACKPAD -> TrackpadView(context, element.control == InputControl.RIGHT_PAD, source = source)
InputElementKind.DPAD -> DirectionPad(context, source)
InputElementKind.DPAD -> DirectionPad(context, element, source)
else -> HoldButton(context, element, source)
}.apply { tag = element; contentDescription = element.name; styleElement(this); source?.onStateChanged = { postInvalidate() } })
}
@@ -541,6 +543,7 @@ class InputSurfaceView(
if (touching || clicking) shade(color, 160, 1f) else shade(color, 120, 0.35f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
if (!element.showLabel) return
labelPaint.color = textColor(touching)
if (drawDirectionLabels(canvas, source, false, cx, cy, radius * 0.66f, radius * 0.34f, labelPaint)) return
labelPaint.textSize = knobRadius * 0.8f
val label = if (element.name == element.control.label) glyph(element.control) else element.name
canvas.drawText(label, cx + knobX, cy + knobY + labelPaint.textSize * 0.35f, labelPaint)
@@ -552,6 +555,9 @@ class InputSurfaceView(
canvas.drawCircle(cx, cy, reach(), ring)
knob.color = if (touching) ACCENT else KNOB
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
if (!element.showLabel) return
labelPaint.color = if (touching) ACCENT else TEXT
drawDirectionLabels(canvas, source, false, cx, cy, reach() * 1.32f, reach() * 0.68f, labelPaint)
}
@SuppressLint("ClickableViewAccessibility")
@@ -613,10 +619,11 @@ class InputSurfaceView(
}
}
private inner class DirectionPad(context: Context, private val source: InputSurfaceSource? = null) : View(context) {
private inner class DirectionPad(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null) : View(context) {
private val density = resources.displayMetrics.density
private var held = 0
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD); color = TEXT }
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
@@ -636,6 +643,7 @@ class InputSurfaceView(
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
arm.color = KNOB
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
if (element.showLabel) drawDirectionLabels(canvas, source, true, cx, cy, (half + thick) / 2f, thick * 1.7f, label)
}
@SuppressLint("ClickableViewAccessibility")
@@ -717,6 +725,29 @@ class InputSurfaceView(
else -> control.id.uppercase()
}
private val CARDINAL = listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)
private val DIAGONAL = listOf(.7071f to -.7071f, .7071f to .7071f, -.7071f to .7071f, -.7071f to -.7071f)
internal fun directionLabels(context: Context, action: InputAction.Directions?, cardinalOnly: Boolean): List<Pair<Pair<Float, Float>, String>> {
if (action == null) return emptyList()
val codes = listOf(action.up, action.right, action.down, action.left).zip(CARDINAL) +
if (cardinalOnly) emptyList() else action.diagonals.orEmpty().zip(DIAGONAL)
return codes.filter { it.first != 0 }.map { (code, unit) -> unit to pcKeyLabel(context, code) }
}
private fun View.drawDirectionLabels(canvas: Canvas, source: InputSurfaceSource?, cardinalOnly: Boolean, cx: Float, cy: Float,
distance: Float, box: Float, paint: Paint): Boolean {
val labels = directionLabels(context, source?.directions, cardinalOnly)
paint.style = Paint.Style.FILL
for ((unit, text) in labels) {
paint.textSize = box * .6f
val measured = paint.measureText(text)
if (measured > box) paint.textSize *= box / measured
canvas.drawText(text, cx + unit.first * distance, cy + unit.second * distance - (paint.ascent() + paint.descent()) / 2f, paint)
}
return labels.isNotEmpty()
}
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int, paint: Paint) {
val (fx, fy) = when (direction) { 0 -> 0f to -1f; 1 -> 1f to 0f; 2 -> 0f to 1f; else -> -1f to 0f }
val path = Path()
@@ -109,6 +109,7 @@ import com.droiddeck.launcher.stores.itch.ItchUpdates
import com.droiddeck.launcher.stores.gog.GogMaintenance
import com.droiddeck.launcher.stores.retainOrder
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
import com.droiddeck.launcher.stores.entryTime
import com.droiddeck.launcher.stores.sortGames
import kotlinx.coroutines.launch
import androidx.compose.runtime.setValue
@@ -296,8 +297,8 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null }
val sorted = sortGames(
candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() },
lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L },
installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L },
lastPlayed = { e -> entryTime(e.game?.lastPlayed, e.storeKey?.let { lastPlayed[it] }) },
installedAt = { e -> entryTime(e.storeKey?.let { installedAt[it] }, e.game?.installedAt) },
size = { e -> sizeOf(e) ?: 0L },
)
var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) }
@@ -224,6 +224,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null,
listOf("top" to stringResource(R.string.display_position_top),
"bottom" to stringResource(R.string.display_position_bottom),
"left" to stringResource(R.string.display_position_left),
"right" to stringResource(R.string.display_position_right),
"center" to stringResource(R.string.display_position_center)),
position, onPick = a.onPicturePosition)
}
@@ -323,7 +323,7 @@ class SecondScreenPresentation(
context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() }))
SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel))
SecondScreenMode.DASHBOARD -> model(swap)
SecondScreenMode.DASHBOARD, SecondScreenMode.CUSTOM_CONTROLS -> model(swap)
SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard,
tag = "bottom-keyboard") { terminalPanel?.showKeyboard() }))
else -> null
@@ -219,7 +219,6 @@ class DrawerActions(
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val micAvailable: Boolean = false,
@@ -325,6 +324,7 @@ class DrawerActions(
val onStop: () -> Unit,
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
val onDismissScreen: (() -> Unit)? = null,
val components: ComponentsManager.Snapshot? = null,
val systemPowerVr: Boolean = false,
val onComponentsRefresh: () -> Unit = {},
@@ -488,7 +488,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
) {
SteamActionPill(
stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"),
) { host.open = null; a.onClose() }
) { host.open = null; (a.onDismissScreen ?: a.onClose)() }
if (roomyHeader) Spacer(Modifier.weight(1f))
SteamActionPill(
stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"),
@@ -98,6 +98,7 @@ internal class HudData(
val tempColor: (HudMetrics.TempSensor, Int) -> Color,
val redC: (HudMetrics.TempSensor) -> Int?,
val historySeconds: Int = 0,
val graphEnd: Long = 0L,
) {
val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it }
val hz: Int get() = if (refreshHz > 0) refreshHz else 60
@@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean =
private val GRAPH_MIN_GAP = 3.dp
@Composable
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier) {
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) {
Canvas(modifier) {
if (values.size < 2 || hi <= lo) return@Canvas
val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1)
val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() }
if (points.size < 2) return@Canvas
// Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps
// its bucket as the window scrolls; counted from the window's start, every new frame re-paired them.
val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt()
val path = Path()
points.forEachIndexed { i, v ->
val x = i / (points.size - 1f) * size.width
val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
var drawn = 0
var start = skip
while (start < values.size) {
val stop = minOf(start + stride, values.size)
var sum = 0f
for (i in start until stop) sum += values[i]
val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width
val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y)
start = stop
}
if (drawn < 2) return@Canvas
drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
@@ -287,7 +296,7 @@ private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modif
@Composable
private fun FrameGraph(d: HudData, accent: Color, height: Dp) {
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height))
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd)
}
@Composable
@@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) {
drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash)
}
}
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize())
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd)
for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) {
Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color,
Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))
@@ -58,6 +58,7 @@ object Themes {
const val RGB_SPEEDS = 10
const val DEFAULT_RGB_SPEED = 3
const val RGB_HUE_STEP = 3f
const val WHITE_RED = "white_red"
var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED)
private set
@@ -127,6 +128,13 @@ object Themes {
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
good = Color(0xFF4CD37F),
),
Palette(
WHITE_RED, R.string.theme_white_red, R.string.theme_white_red_detail,
background = Color(0xFFF8F8F8), surface = Color(0xFFFFFFFF), surfaceVariant = Color(0xFFF0F0F0), line = Color(0xFFDDDDDD), line2 = Color(0xFFC8C8C8),
onBackground = Color(0xFF191919), onSurfaceVariant = Color(0xFF555555),
primary = Color(0xFFD62828), primary2 = Color(0xFFB71C1C), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFFD62828),
good = Color(0xFF248548),
),
innerGamer(0f),
ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail),
)
+5 -8
View File
@@ -1259,7 +1259,6 @@
<string name="deck_tab_gamepad">يد التحكّم</string>
<string name="deck_surface_stick">عصا التحكّم</string>
<string name="deck_surface_trackpad">لوحة اللمس</string>
<string name="deck_surface_switch">التبديل بين عصا التحكّم ولوحة اللمس</string>
<string name="deck_close">إغلاق عناصر التحكّم على الشاشة الثانية</string>
<string name="deck_open_steam_menu">فتح قائمة Steam</string>
<string name="deck_right_trackpad">لوحة اللمس اليمنى</string>
@@ -1874,13 +1873,6 @@
<string name="bottom_downloads_shader">جارٍ تنزيل ذاكرة المظلِّلات المؤقتة</string>
<string name="bottom_downloads_paused">متوقف مؤقتًا</string>
<string name="bottom_downloads_queued">بانتظار البدء</string>
<string name="bottom_page_notifications">الإشعارات</string>
<string name="bottom_notifications_listener">الشاشة السفلية لـ DroidDeck</string>
<string name="bottom_notifications_why">اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا.</string>
<string name="bottom_notifications_allow">السماح بالوصول إلى الإشعارات</string>
<string name="bottom_notifications_none">لا توجد إشعارات.</string>
<string name="bottom_notifications_count">الإشعارات: %1$d</string>
<string name="bottom_notifications_clear">مسح</string>
<string name="bottom_page_screenshots">لقطات الشاشة</string>
<string name="stats_fps_needs_hud">يتطلب FPS تشغيل واجهة الأداء</string>
<string name="stats_speed">السرعة</string>
@@ -2984,6 +2976,8 @@
<string name="display_position_top">أعلى</string>
<string name="display_position_bottom">أسفل</string>
<string name="display_position_center">الوسط (افتراضي)</string>
<string name="display_position_left">يسار</string>
<string name="display_position_right">يمين</string>
<string name="setup_tab_storage">التخزين</string>
<string name="storage_measuring">جارٍ قياس التخزين…</string>
<string name="storage_measuring_hint">يستغرق القياس الأول بضع ثوانٍ.</string>
@@ -3817,4 +3811,7 @@
<string name="input_reset_positions">إعادة تعيين المواضع</string>
<string name="input_show_label">إظهار التسمية</string>
<string name="input_layout_import_failed">تعذّر استيراد هذا التخطيط</string>
<string name="theme_white_red">ثلج قرمزي</string>
</resources>
+4 -8
View File
@@ -1308,7 +1308,6 @@
<string name="deck_tab_gamepad">Control</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
@@ -1866,13 +1865,6 @@
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
<string name="bottom_downloads_paused">En pausa</string>
<string name="bottom_downloads_queued">Esperando para empezar</string>
<string name="bottom_page_notifications">Notificaciones</string>
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
<string name="bottom_notifications_none">No hay notificaciones.</string>
<string name="bottom_notifications_count">%1$d notificaciones</string>
<string name="bottom_notifications_clear">Borrar</string>
<string name="bottom_page_screenshots">Capturas de pantalla</string>
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
<string name="stats_speed">Velocidad</string>
@@ -2836,6 +2828,8 @@
<string name="display_position_top">Arriba</string>
<string name="display_position_bottom">Abajo</string>
<string name="display_position_center">Centro (predeterminado)</string>
<string name="display_position_left">Izquierda</string>
<string name="display_position_right">Derecha</string>
<string name="setup_tab_storage">Almacenamiento</string>
<string name="storage_measuring">Midiendo el almacenamiento…</string>
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
@@ -3673,4 +3667,6 @@
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se pudo importar esta disposición</string>
<string name="theme_white_red">Nieve escarlata</string>
<string name="theme_white_red_detail">Superficies blancas con acentos rojos.</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стик</string>
<string name="deck_surface_trackpad">Тракпад</string>
<string name="deck_surface_switch">Превключване между стик и тракпад</string>
<string name="deck_close">Затваряне на контролите за втория екран</string>
<string name="deck_open_steam_menu">Отваряне на менюто на Steam</string>
<string name="deck_right_trackpad">Десен тракпад</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Изтегляне на кеша на шейдърите</string>
<string name="bottom_downloads_paused">На пауза</string>
<string name="bottom_downloads_queued">Изчаква стартиране</string>
<string name="bottom_page_notifications">Известия</string>
<string name="bottom_notifications_listener">Долен екран на DroidDeck</string>
<string name="bottom_notifications_why">Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама.</string>
<string name="bottom_notifications_allow">Разрешаване на достъпа до известията</string>
<string name="bottom_notifications_none">Няма известия.</string>
<string name="bottom_notifications_count">Известия: %1$d</string>
<string name="bottom_notifications_clear">Изчисти</string>
<string name="bottom_page_screenshots">Екранни снимки</string>
<string name="stats_fps_needs_hud">За FPS трябва HUD да е включен</string>
<string name="stats_speed">Скорост</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Горе</string>
<string name="display_position_bottom">Долу</string>
<string name="display_position_center">В средата (по подразбиране)</string>
<string name="display_position_left">Наляво</string>
<string name="display_position_right">Надясно</string>
<string name="setup_tab_storage">Съхранение</string>
<string name="storage_measuring">Измерване на съхранението…</string>
<string name="storage_measuring_hint">Първият път отнема няколко секунди.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Нулиране на позициите</string>
<string name="input_show_label">Показване на етикета</string>
<string name="input_layout_import_failed">Това разположение не можа да бъде импортирано</string>
<string name="theme_white_red">Ален сняг</string>
</resources>
+5 -8
View File
@@ -1245,7 +1245,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Páčka</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Přepnout mezi páčkou a trackpadem</string>
<string name="deck_close">Zavřít ovládání druhé obrazovky</string>
<string name="deck_open_steam_menu">Otevřít nabídku Steamu</string>
<string name="deck_right_trackpad">Pravý trackpad</string>
@@ -1826,13 +1825,6 @@
<string name="bottom_downloads_shader">Stahování mezipaměti shaderů</string>
<string name="bottom_downloads_paused">Pozastaveno</string>
<string name="bottom_downloads_queued">Čeká na spuštění</string>
<string name="bottom_page_notifications">Oznámení</string>
<string name="bottom_notifications_listener">Spodní obrazovka DroidDeck</string>
<string name="bottom_notifications_why">Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí.</string>
<string name="bottom_notifications_allow">Povolit přístup k oznámením</string>
<string name="bottom_notifications_none">Žádná oznámení.</string>
<string name="bottom_notifications_count">Oznámení: %1$d</string>
<string name="bottom_notifications_clear">Vymazat</string>
<string name="bottom_page_screenshots">Snímky obrazovky</string>
<string name="stats_fps_needs_hud">FPS vyžaduje zapnutý HUD</string>
<string name="stats_speed">Rychlost</string>
@@ -2926,6 +2918,8 @@
<string name="display_position_top">Nahoře</string>
<string name="display_position_bottom">Dole</string>
<string name="display_position_center">Uprostřed (výchozí)</string>
<string name="display_position_left">Vlevo</string>
<string name="display_position_right">Vpravo</string>
<string name="setup_tab_storage">Úložiště</string>
<string name="storage_measuring">Měření úložiště…</string>
<string name="storage_measuring_hint">Poprvé to trvá pár sekund.</string>
@@ -3739,4 +3733,7 @@
<string name="input_reset_positions">Obnovit pozice</string>
<string name="input_show_label">Zobrazit popisek</string>
<string name="input_layout_import_failed">Toto rozložení nelze importovat</string>
<string name="theme_white_red">Šarlatový sníh</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Skift mellem stick og trackpad</string>
<string name="deck_close">Luk kontrollerne til den anden skærm</string>
<string name="deck_open_steam_menu">Åbn Steam-menuen</string>
<string name="deck_right_trackpad">Højre trackpad</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Downloader shader-cache</string>
<string name="bottom_downloads_paused">På pause</string>
<string name="bottom_downloads_queued">Venter på at starte</string>
<string name="bottom_page_notifications">Notifikationer</string>
<string name="bottom_notifications_listener">DroidDecks nederste skærm</string>
<string name="bottom_notifications_why">Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage.</string>
<string name="bottom_notifications_allow">Tillad notifikationsadgang</string>
<string name="bottom_notifications_none">Ingen notifikationer.</string>
<string name="bottom_notifications_count">%1$d notifikationer</string>
<string name="bottom_notifications_clear">Ryd</string>
<string name="bottom_page_screenshots">Skærmbilleder</string>
<string name="stats_fps_needs_hud">FPS kræver, at HUD er slået til</string>
<string name="stats_speed">Hastighed</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Øverst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Midten (standard)</string>
<string name="display_position_left">Venstre</string>
<string name="display_position_right">Højre</string>
<string name="setup_tab_storage">Lager</string>
<string name="storage_measuring">Måler lager…</string>
<string name="storage_measuring_hint">Første gang tager et par sekunder.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Nulstil placeringer</string>
<string name="input_show_label">Vis etiket</string>
<string name="input_layout_import_failed">Dette layout kunne ikke importeres</string>
<string name="theme_white_red">Skarlagensne</string>
</resources>
+5 -8
View File
@@ -1223,7 +1223,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Zwischen Stick und Trackpad wechseln</string>
<string name="deck_close">Steuerung auf dem Zweitbildschirm schließen</string>
<string name="deck_open_steam_menu">Steam-Menü öffnen</string>
<string name="deck_right_trackpad">Rechtes Trackpad</string>
@@ -1770,13 +1769,6 @@
<string name="bottom_downloads_shader">Shader-Cache wird heruntergeladen</string>
<string name="bottom_downloads_paused">Pausiert</string>
<string name="bottom_downloads_queued">Wartet auf Start</string>
<string name="bottom_page_notifications">Benachrichtigungen</string>
<string name="bottom_notifications_listener">DroidDeck – unterer Bildschirm</string>
<string name="bottom_notifications_why">Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück.</string>
<string name="bottom_notifications_allow">Benachrichtigungszugriff erlauben</string>
<string name="bottom_notifications_none">Keine Benachrichtigungen.</string>
<string name="bottom_notifications_count">%1$d Benachrichtigungen</string>
<string name="bottom_notifications_clear">Löschen</string>
<string name="bottom_page_screenshots">Screenshots</string>
<string name="stats_fps_needs_hud">FPS benötigt das aktive HUD</string>
<string name="stats_speed">Geschwindigkeit</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Oben</string>
<string name="display_position_bottom">Unten</string>
<string name="display_position_center">Mitte (Standard)</string>
<string name="display_position_left">Links</string>
<string name="display_position_right">Rechts</string>
<string name="setup_tab_storage">Speicher</string>
<string name="storage_measuring">Speicher wird erfasst…</string>
<string name="storage_measuring_hint">Die erste Erfassung dauert ein paar Sekunden.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Positionen zurücksetzen</string>
<string name="input_show_label">Beschriftung anzeigen</string>
<string name="input_layout_import_failed">Dieses Layout konnte nicht importiert werden</string>
<string name="theme_white_red">Scharlachroter Schnee</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Χειριστήριο</string>
<string name="deck_surface_stick">Μοχλός</string>
<string name="deck_surface_trackpad">Πινακίδα αφής</string>
<string name="deck_surface_switch">Εναλλαγή μεταξύ μοχλού και πινακίδας αφής</string>
<string name="deck_close">Κλείσιμο χειριστηρίων δεύτερης οθόνης</string>
<string name="deck_open_steam_menu">Άνοιγμα μενού Steam</string>
<string name="deck_right_trackpad">Δεξιά πινακίδα αφής</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Λήψη cache shader</string>
<string name="bottom_downloads_paused">Σε παύση</string>
<string name="bottom_downloads_queued">Αναμονή για έναρξη</string>
<string name="bottom_page_notifications">Ειδοποιήσεις</string>
<string name="bottom_notifications_listener">DroidDeck – κάτω οθόνη</string>
<string name="bottom_notifications_why">Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της.</string>
<string name="bottom_notifications_allow">Αποδοχή πρόσβασης ειδοποιήσεων</string>
<string name="bottom_notifications_none">Δεν υπάρχουν ειδοποιήσεις.</string>
<string name="bottom_notifications_count">%1$d ειδοποιήσεις</string>
<string name="bottom_notifications_clear">Απαλοιφή</string>
<string name="bottom_page_screenshots">Στιγμιότυπα οθόνης</string>
<string name="stats_fps_needs_hud">Τα FPS απαιτούν ενεργό HUD</string>
<string name="stats_speed">Ταχύτητα</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Πάνω</string>
<string name="display_position_bottom">Κάτω</string>
<string name="display_position_center">Κέντρο (προεπιλογή)</string>
<string name="display_position_left">Αριστερά</string>
<string name="display_position_right">Δεξιά</string>
<string name="setup_tab_storage">Αποθήκευση</string>
<string name="storage_measuring">Μέτρηση αποθήκευσης…</string>
<string name="storage_measuring_hint">Την πρώτη φορά παίρνει λίγα δευτερόλεπτα.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Επαναφορά θέσεων</string>
<string name="input_show_label">Εμφάνιση ετικέτας</string>
<string name="input_layout_import_failed">Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης</string>
<string name="theme_white_red">Κατακόκκινο χιόνι</string>
</resources>
+5 -8
View File
@@ -1238,7 +1238,6 @@
<string name="deck_tab_gamepad">Mando</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
@@ -1802,13 +1801,6 @@
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
<string name="bottom_downloads_paused">En pausa</string>
<string name="bottom_downloads_queued">Esperando para empezar</string>
<string name="bottom_page_notifications">Notificaciones</string>
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
<string name="bottom_notifications_none">No hay notificaciones.</string>
<string name="bottom_notifications_count">%1$d notificaciones</string>
<string name="bottom_notifications_clear">Borrar</string>
<string name="bottom_page_screenshots">Capturas de pantalla</string>
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
<string name="stats_speed">Velocidad</string>
@@ -2897,6 +2889,8 @@
<string name="display_position_top">Arriba</string>
<string name="display_position_bottom">Abajo</string>
<string name="display_position_center">Centro (predeterminado)</string>
<string name="display_position_left">Izquierda</string>
<string name="display_position_right">Derecha</string>
<string name="setup_tab_storage">Almacenamiento</string>
<string name="storage_measuring">Midiendo el almacenamiento…</string>
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
@@ -3700,4 +3694,7 @@
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se ha podido importar esta disposición</string>
<string name="theme_white_red">Nieve escarlata</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Peliohjain</string>
<string name="deck_surface_stick">Tatti</string>
<string name="deck_surface_trackpad">Kosketuslevy</string>
<string name="deck_surface_switch">Vaihda tatin ja kosketuslevyn välillä</string>
<string name="deck_close">Sulje toisen näytön ohjaimet</string>
<string name="deck_open_steam_menu">Avaa Steam-valikko</string>
<string name="deck_right_trackpad">Oikea kosketuslevy</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Ladataan sävytinvälimuistia</string>
<string name="bottom_downloads_paused">Keskeytetty</string>
<string name="bottom_downloads_queued">Odottaa alkamista</string>
<string name="bottom_page_notifications">Ilmoitukset</string>
<string name="bottom_notifications_listener">DroidDeckin alanäyttö</string>
<string name="bottom_notifications_why">Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään.</string>
<string name="bottom_notifications_allow">Salli ilmoitusten käyttö</string>
<string name="bottom_notifications_none">Ei ilmoituksia.</string>
<string name="bottom_notifications_count">Ilmoituksia: %1$d</string>
<string name="bottom_notifications_clear">Tyhjennä</string>
<string name="bottom_page_screenshots">Kuvakaappaukset</string>
<string name="stats_fps_needs_hud">FPS vaatii HUD:n päälle</string>
<string name="stats_speed">Nopeus</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Ylhäällä</string>
<string name="display_position_bottom">Alhaalla</string>
<string name="display_position_center">Keskellä (oletus)</string>
<string name="display_position_left">Vasen</string>
<string name="display_position_right">Oikea</string>
<string name="setup_tab_storage">Tallennustila</string>
<string name="storage_measuring">Mitataan tallennustilaa…</string>
<string name="storage_measuring_hint">Ensimmäisellä kerralla kestää muutaman sekunnin.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Palauta sijainnit</string>
<string name="input_show_label">Näytä nimi</string>
<string name="input_layout_import_failed">Tätä asettelua ei voitu tuoda</string>
<string name="theme_white_red">Helakanpunainen lumi</string>
</resources>
+5 -8
View File
@@ -1238,7 +1238,6 @@
<string name="deck_tab_gamepad">Manette</string>
<string name="deck_surface_stick">Joystick</string>
<string name="deck_surface_trackpad">Pavé tactile</string>
<string name="deck_surface_switch">Basculer entre joystick et pavé tactile</string>
<string name="deck_close">Fermer les commandes du deuxième écran</string>
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
<string name="deck_right_trackpad">Pavé tactile droit</string>
@@ -1802,13 +1801,6 @@
<string name="bottom_downloads_shader">Téléchargement du cache de shaders</string>
<string name="bottom_downloads_paused">En pause</string>
<string name="bottom_downloads_queued">En attente de démarrage</string>
<string name="bottom_page_notifications">Notifications</string>
<string name="bottom_notifications_listener">Écran inférieur DroidDeck</string>
<string name="bottom_notifications_why">Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même.</string>
<string name="bottom_notifications_allow">Autoriser l\'accès aux notifications</string>
<string name="bottom_notifications_none">Aucune notification.</string>
<string name="bottom_notifications_count">%1$d notifications</string>
<string name="bottom_notifications_clear">Effacer</string>
<string name="bottom_page_screenshots">Captures d\'écran</string>
<string name="stats_fps_needs_hud">Les FPS nécessitent d\'activer le HUD</string>
<string name="stats_speed">Vitesse</string>
@@ -2897,6 +2889,8 @@
<string name="display_position_top">Haut</string>
<string name="display_position_bottom">Bas</string>
<string name="display_position_center">Centre (par défaut)</string>
<string name="display_position_left">Gauche</string>
<string name="display_position_right">Droite</string>
<string name="setup_tab_storage">Stockage</string>
<string name="storage_measuring">Mesure du stockage…</string>
<string name="storage_measuring_hint">Cela prend quelques secondes la première fois.</string>
@@ -3700,4 +3694,7 @@
<string name="input_reset_positions">Réinitialiser les positions</string>
<string name="input_show_label">Afficher le libellé</string>
<string name="input_layout_import_failed">Impossible d’importer cette disposition</string>
<string name="theme_white_red">Neige écarlate</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">गेमपैड</string>
<string name="deck_surface_stick">स्टिक</string>
<string name="deck_surface_trackpad">ट्रैकपैड</string>
<string name="deck_surface_switch">स्टिक और ट्रैकपैड के बीच बदलें</string>
<string name="deck_close">दूसरी स्क्रीन के कंट्रोल बंद करें</string>
<string name="deck_open_steam_menu">Steam मेनू खोलें</string>
<string name="deck_right_trackpad">दायां ट्रैकपैड</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">शेडर कैश डाउनलोड हो रहा है</string>
<string name="bottom_downloads_paused">रुका हुआ</string>
<string name="bottom_downloads_queued">शुरू होने का इंतज़ार</string>
<string name="bottom_page_notifications">नोटिफ़िकेशन</string>
<string name="bottom_notifications_listener">DroidDeck नीचे की स्क्रीन</string>
<string name="bottom_notifications_why">खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा।</string>
<string name="bottom_notifications_allow">नोटिफ़िकेशन ऐक्सेस की अनुमति दें</string>
<string name="bottom_notifications_none">कोई नोटिफ़िकेशन नहीं।</string>
<string name="bottom_notifications_count">%1$d नोटिफ़िकेशन</string>
<string name="bottom_notifications_clear">साफ़ करें</string>
<string name="bottom_page_screenshots">स्क्रीनशॉट</string>
<string name="stats_fps_needs_hud">FPS के लिए HUD चालू होना चाहिए</string>
<string name="stats_speed">स्पीड</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">ऊपर</string>
<string name="display_position_bottom">नीचे</string>
<string name="display_position_center">बीच में (डिफ़ॉल्ट)</string>
<string name="display_position_left">बाएँ</string>
<string name="display_position_right">दाएँ</string>
<string name="setup_tab_storage">स्टोरेज</string>
<string name="storage_measuring">स्टोरेज मापा जा रहा है…</string>
<string name="storage_measuring_hint">पहली बार कुछ सेकंड लगते हैं।</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">स्थितियाँ रीसेट करें</string>
<string name="input_show_label">लेबल दिखाएँ</string>
<string name="input_layout_import_failed">यह लेआउट इम्पोर्ट नहीं किया जा सका</string>
<string name="theme_white_red">किरमिज़ी बर्फ</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Kontroller</string>
<string name="deck_surface_stick">Kar</string>
<string name="deck_surface_trackpad">Érintőpad</string>
<string name="deck_surface_switch">Váltás a kar és az érintőpad között</string>
<string name="deck_close">Második képernyős vezérlők bezárása</string>
<string name="deck_open_steam_menu">Steam-menü megnyitása</string>
<string name="deck_right_trackpad">Jobb érintőpad</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Shader-gyorsítótár letöltése</string>
<string name="bottom_downloads_paused">Szüneteltetve</string>
<string name="bottom_downloads_queued">Indításra vár</string>
<string name="bottom_page_notifications">Értesítések</string>
<string name="bottom_notifications_listener">DroidDeck alsó képernyő</string>
<string name="bottom_notifications_why">Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér.</string>
<string name="bottom_notifications_allow">Értesítési hozzáférés engedélyezése</string>
<string name="bottom_notifications_none">Nincsenek értesítések.</string>
<string name="bottom_notifications_count">%1$d értesítés</string>
<string name="bottom_notifications_clear">Törlés</string>
<string name="bottom_page_screenshots">Képernyőképek</string>
<string name="stats_fps_needs_hud">Az FPS-hez be kell kapcsolni a HUD-ot</string>
<string name="stats_speed">Sebesség</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Fent</string>
<string name="display_position_bottom">Lent</string>
<string name="display_position_center">Középen (alapértelmezett)</string>
<string name="display_position_left">Bal</string>
<string name="display_position_right">Jobb</string>
<string name="setup_tab_storage">Tárhely</string>
<string name="storage_measuring">Tárhely mérése…</string>
<string name="storage_measuring_hint">Első alkalommal pár másodpercig tart.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Pozíciók visszaállítása</string>
<string name="input_show_label">Címke megjelenítése</string>
<string name="input_layout_import_failed">Ez az elrendezés nem importálható</string>
<string name="theme_white_red">Skarlátvörös hó</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stik</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Beralih antara stik dan trackpad</string>
<string name="deck_close">Tutup kontrol layar kedua</string>
<string name="deck_open_steam_menu">Buka menu Steam</string>
<string name="deck_right_trackpad">Trackpad kanan</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">Mengunduh cache shader</string>
<string name="bottom_downloads_paused">Dijeda</string>
<string name="bottom_downloads_queued">Menunggu dimulai</string>
<string name="bottom_page_notifications">Notifikasi</string>
<string name="bottom_notifications_listener">Layar bawah DroidDeck</string>
<string name="bottom_notifications_why">Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
<string name="bottom_notifications_allow">Izinkan akses notifikasi</string>
<string name="bottom_notifications_none">Tidak ada notifikasi.</string>
<string name="bottom_notifications_count">%1$d notifikasi</string>
<string name="bottom_notifications_clear">Hapus</string>
<string name="bottom_page_screenshots">Tangkapan layar</string>
<string name="stats_fps_needs_hud">FPS memerlukan HUD aktif</string>
<string name="stats_speed">Kecepatan</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">Atas</string>
<string name="display_position_bottom">Bawah</string>
<string name="display_position_center">Tengah (bawaan)</string>
<string name="display_position_left">Kiri</string>
<string name="display_position_right">Kanan</string>
<string name="setup_tab_storage">Penyimpanan</string>
<string name="storage_measuring">Mengukur penyimpanan…</string>
<string name="storage_measuring_hint">Butuh beberapa detik pada kali pertama.</string>
@@ -3631,4 +3625,7 @@
<string name="input_reset_positions">Atur ulang posisi</string>
<string name="input_show_label">Tampilkan label</string>
<string name="input_layout_import_failed">Tata letak ini tidak dapat diimpor</string>
<string name="theme_white_red">Salju Kirmizi</string>
</resources>
+5 -8
View File
@@ -1230,7 +1230,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Levetta</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alterna tra levetta e trackpad</string>
<string name="deck_close">Chiudi i controlli sul secondo schermo</string>
<string name="deck_open_steam_menu">Apri il menu Steam</string>
<string name="deck_right_trackpad">Trackpad destro</string>
@@ -1794,13 +1793,6 @@
<string name="bottom_downloads_shader">Download della cache degli shader</string>
<string name="bottom_downloads_paused">In pausa</string>
<string name="bottom_downloads_queued">In attesa di avvio</string>
<string name="bottom_page_notifications">Notifiche</string>
<string name="bottom_notifications_listener">Schermo inferiore DroidDeck</string>
<string name="bottom_notifications_why">Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola.</string>
<string name="bottom_notifications_allow">Consenti accesso alle notifiche</string>
<string name="bottom_notifications_none">Nessuna notifica.</string>
<string name="bottom_notifications_count">%1$d notifiche</string>
<string name="bottom_notifications_clear">Cancella</string>
<string name="bottom_page_screenshots">Screenshot</string>
<string name="stats_fps_needs_hud">Gli FPS richiedono l\'HUD attivo</string>
<string name="stats_speed">Velocità</string>
@@ -2897,6 +2889,8 @@
<string name="display_position_top">In alto</string>
<string name="display_position_bottom">In basso</string>
<string name="display_position_center">Centro (predefinito)</string>
<string name="display_position_left">Sinistra</string>
<string name="display_position_right">Destra</string>
<string name="setup_tab_storage">Archiviazione</string>
<string name="storage_measuring">Misurazione dell\'archiviazione…</string>
<string name="storage_measuring_hint">La prima volta richiede qualche secondo.</string>
@@ -3700,4 +3694,7 @@
<string name="input_reset_positions">Ripristina posizioni</string>
<string name="input_show_label">Mostra etichetta</string>
<string name="input_layout_import_failed">Impossibile importare questo layout</string>
<string name="theme_white_red">Neve scarlatta</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">ゲームパッド</string>
<string name="deck_surface_stick">スティック</string>
<string name="deck_surface_trackpad">トラックパッド</string>
<string name="deck_surface_switch">スティックとトラックパッドを切り替え</string>
<string name="deck_close">セカンドスクリーンコントロールを閉じる</string>
<string name="deck_open_steam_menu">Steamメニューを開く</string>
<string name="deck_right_trackpad">右トラックパッド</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">シェーダーキャッシュをダウンロード中</string>
<string name="bottom_downloads_paused">一時停止中</string>
<string name="bottom_downloads_queued">開始待ち</string>
<string name="bottom_page_notifications">通知</string>
<string name="bottom_notifications_listener">DroidDeck下部画面</string>
<string name="bottom_notifications_why">プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。</string>
<string name="bottom_notifications_allow">通知へのアクセスを許可</string>
<string name="bottom_notifications_none">通知はありません。</string>
<string name="bottom_notifications_count">%1$d件の通知</string>
<string name="bottom_notifications_clear">クリア</string>
<string name="bottom_page_screenshots">スクリーンショット</string>
<string name="stats_fps_needs_hud">FPSの表示にはHUDをオンにする必要があります</string>
<string name="stats_speed">速度</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">上</string>
<string name="display_position_bottom">下</string>
<string name="display_position_center">中央(既定)</string>
<string name="display_position_left">左</string>
<string name="display_position_right">右</string>
<string name="setup_tab_storage">ストレージ</string>
<string name="storage_measuring">ストレージを集計しています…</string>
<string name="storage_measuring_hint">初回の集計には数秒かかります。</string>
@@ -3631,4 +3625,7 @@
<string name="input_reset_positions">位置をリセット</string>
<string name="input_show_label">ラベルを表示</string>
<string name="input_layout_import_failed">このレイアウトをインポートできませんでした</string>
<string name="theme_white_red">緋色の雪</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">게임패드</string>
<string name="deck_surface_stick">스틱</string>
<string name="deck_surface_trackpad">터치패드</string>
<string name="deck_surface_switch">스틱과 터치패드 간 전환</string>
<string name="deck_close">보조 화면 컨트롤 닫기</string>
<string name="deck_open_steam_menu">Steam 메뉴 열기</string>
<string name="deck_right_trackpad">오른쪽 터치패드</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">셰이더 캐시 다운로드 중</string>
<string name="bottom_downloads_paused">일시 중지됨</string>
<string name="bottom_downloads_queued">시작 대기 중</string>
<string name="bottom_page_notifications">알림</string>
<string name="bottom_notifications_listener">DroidDeck 하단 화면</string>
<string name="bottom_notifications_why">플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다.</string>
<string name="bottom_notifications_allow">알림 접근 허용</string>
<string name="bottom_notifications_none">알림이 없습니다.</string>
<string name="bottom_notifications_count">알림 %1$d개</string>
<string name="bottom_notifications_clear">지우기</string>
<string name="bottom_page_screenshots">스크린샷</string>
<string name="stats_fps_needs_hud">FPS를 보려면 HUD를 켜야 합니다</string>
<string name="stats_speed">속도</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">위</string>
<string name="display_position_bottom">아래</string>
<string name="display_position_center">가운데(기본)</string>
<string name="display_position_left">왼쪽</string>
<string name="display_position_right">오른쪽</string>
<string name="setup_tab_storage">저장 공간</string>
<string name="storage_measuring">저장 공간 계산 중…</string>
<string name="storage_measuring_hint">처음 계산할 때는 몇 초 걸립니다.</string>
@@ -3631,4 +3625,7 @@
<string name="input_reset_positions">위치 초기화</string>
<string name="input_show_label">라벨 표시</string>
<string name="input_layout_import_failed">이 레이아웃을 가져올 수 없습니다</string>
<string name="theme_white_red">진홍빛 눈</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stik</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Tukar antara stik dan trackpad</string>
<string name="deck_close">Tutup kawalan skrin kedua</string>
<string name="deck_open_steam_menu">Buka menu Steam</string>
<string name="deck_right_trackpad">Trackpad kanan</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">Memuat turun cache shader</string>
<string name="bottom_downloads_paused">Dijeda</string>
<string name="bottom_downloads_queued">Menunggu untuk bermula</string>
<string name="bottom_page_notifications">Pemberitahuan</string>
<string name="bottom_notifications_listener">Skrin bawah DroidDeck</string>
<string name="bottom_notifications_why">Paparkan mesej dan pemberitahuan lain di sini semasa anda bermain. Ia hanya kekal pada skrin ini dan tidak disimpan. Android bertanya sekali sahaja: halaman akses pemberitahuannya dibuka pada skrin ini; hidupkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
<string name="bottom_notifications_allow">Benarkan akses pemberitahuan</string>
<string name="bottom_notifications_none">Tiada pemberitahuan.</string>
<string name="bottom_notifications_count">%1$d pemberitahuan</string>
<string name="bottom_notifications_clear">Kosongkan</string>
<string name="bottom_page_screenshots">Tangkapan skrin</string>
<string name="stats_fps_needs_hud">FPS memerlukan HUD dihidupkan</string>
<string name="stats_speed">Kelajuan</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">Atas</string>
<string name="display_position_bottom">Bawah</string>
<string name="display_position_center">Tengah (lalai)</string>
<string name="display_position_left">Kiri</string>
<string name="display_position_right">Kanan</string>
<string name="setup_tab_storage">Storan</string>
<string name="storage_measuring">Mengukur storan…</string>
<string name="storage_measuring_hint">Mengambil beberapa saat pada kali pertama.</string>
@@ -3631,4 +3625,7 @@
<string name="input_reset_positions">Tetapkan semula kedudukan</string>
<string name="input_show_label">Tunjukkan label</string>
<string name="input_layout_import_failed">Susun atur ini tidak dapat diimport</string>
<string name="theme_white_red">Salji Merah Skarlet</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Kontroller</string>
<string name="deck_surface_stick">Stikke</string>
<string name="deck_surface_trackpad">Styreflate</string>
<string name="deck_surface_switch">Bytt mellom stikke og styreflate</string>
<string name="deck_close">Lukk kontroller for andre skjerm</string>
<string name="deck_open_steam_menu">Åpne Steam-menyen</string>
<string name="deck_right_trackpad">Høyre styreflate</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Laster ned shaderbuffer</string>
<string name="bottom_downloads_paused">Pauset</string>
<string name="bottom_downloads_queued">Venter på å starte</string>
<string name="bottom_page_notifications">Varslinger</string>
<string name="bottom_notifications_listener">DroidDeck nedre skjerm</string>
<string name="bottom_notifications_why">Vis meldinger og andre varslinger her mens du spiller. De vises bare på denne skjermen og lagres ikke. Android spør én gang: siden for varseltilgang åpnes på denne skjermen; slå på DroidDeck der, så kommer denne siden tilbake av seg selv.</string>
<string name="bottom_notifications_allow">Tillat varseltilgang</string>
<string name="bottom_notifications_none">Ingen varslinger.</string>
<string name="bottom_notifications_count">%1$d varslinger</string>
<string name="bottom_notifications_clear">Fjern</string>
<string name="bottom_page_screenshots">Skjermbilder</string>
<string name="stats_fps_needs_hud">FPS krever at HUD-en er på</string>
<string name="stats_speed">Fart</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Øverst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Midten (standard)</string>
<string name="display_position_left">Venstre</string>
<string name="display_position_right">Høyre</string>
<string name="setup_tab_storage">Lagring</string>
<string name="storage_measuring">Måler lagring…</string>
<string name="storage_measuring_hint">Første gang tar noen sekunder.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Tilbakestill plasseringer</string>
<string name="input_show_label">Vis etikett</string>
<string name="input_layout_import_failed">Kunne ikke importere dette oppsettet</string>
<string name="theme_white_red">Skarlagenssnø</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Wisselen tussen stick en trackpad</string>
<string name="deck_close">Bediening op tweede scherm sluiten</string>
<string name="deck_open_steam_menu">Steam-menu openen</string>
<string name="deck_right_trackpad">Rechtertrackpad</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Shadercache downloaden</string>
<string name="bottom_downloads_paused">Gepauzeerd</string>
<string name="bottom_downloads_queued">Wacht om te starten</string>
<string name="bottom_page_notifications">Meldingen</string>
<string name="bottom_notifications_listener">DroidDeck onderste scherm</string>
<string name="bottom_notifications_why">Toon berichten en andere meldingen hier terwijl je speelt. Ze blijven alleen op dit scherm en worden niet opgeslagen. Android vraagt dit één keer: de pagina voor meldingstoegang opent op dit scherm; zet DroidDeck daar aan en deze pagina komt vanzelf terug.</string>
<string name="bottom_notifications_allow">Meldingstoegang toestaan</string>
<string name="bottom_notifications_none">Geen meldingen.</string>
<string name="bottom_notifications_count">%1$d meldingen</string>
<string name="bottom_notifications_clear">Wissen</string>
<string name="bottom_page_screenshots">Screenshots</string>
<string name="stats_fps_needs_hud">Voor FPS moet de HUD aan staan</string>
<string name="stats_speed">Snelheid</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Boven</string>
<string name="display_position_bottom">Onder</string>
<string name="display_position_center">Midden (standaard)</string>
<string name="display_position_left">Links</string>
<string name="display_position_right">Rechts</string>
<string name="setup_tab_storage">Opslag</string>
<string name="storage_measuring">Opslag meten…</string>
<string name="storage_measuring_hint">De eerste keer duurt een paar seconden.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Posities herstellen</string>
<string name="input_show_label">Label tonen</string>
<string name="input_layout_import_failed">Deze indeling kan niet worden geïmporteerd</string>
<string name="theme_white_red">Scharlakenrode sneeuw</string>
</resources>
+5 -8
View File
@@ -1245,7 +1245,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Gałka</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Przełącz między gałką a trackpadem</string>
<string name="deck_close">Zamknij sterowanie na drugim ekranie</string>
<string name="deck_open_steam_menu">Otwórz menu Steam</string>
<string name="deck_right_trackpad">Prawy trackpad</string>
@@ -1826,13 +1825,6 @@
<string name="bottom_downloads_shader">Pobieranie pamięci podręcznej shaderów</string>
<string name="bottom_downloads_paused">Wstrzymano</string>
<string name="bottom_downloads_queued">Oczekuje na rozpoczęcie</string>
<string name="bottom_page_notifications">Powiadomienia</string>
<string name="bottom_notifications_listener">Dolny ekran DroidDeck</string>
<string name="bottom_notifications_why">Wyświetlaj tu wiadomości i inne powiadomienia podczas gry. Pozostają tylko na tym ekranie i nie są zapisywane. Android zapyta raz: na tym ekranie otworzy się jego strona dostępu do powiadomień; włącz tam DroidDeck, a ta strona wróci sama.</string>
<string name="bottom_notifications_allow">Zezwól na dostęp do powiadomień</string>
<string name="bottom_notifications_none">Brak powiadomień.</string>
<string name="bottom_notifications_count">Powiadomienia: %1$d</string>
<string name="bottom_notifications_clear">Wyczyść</string>
<string name="bottom_page_screenshots">Zrzuty ekranu</string>
<string name="stats_fps_needs_hud">FPS wymaga włączonego HUD</string>
<string name="stats_speed">Prędkość</string>
@@ -2926,6 +2918,8 @@
<string name="display_position_top">Góra</string>
<string name="display_position_bottom">Dół</string>
<string name="display_position_center">Środek (domyślnie)</string>
<string name="display_position_left">Lewo</string>
<string name="display_position_right">Prawo</string>
<string name="setup_tab_storage">Pamięć</string>
<string name="storage_measuring">Mierzenie pamięci…</string>
<string name="storage_measuring_hint">Pierwszy raz trwa kilka sekund.</string>
@@ -3739,4 +3733,7 @@
<string name="input_reset_positions">Resetuj położenie</string>
<string name="input_show_label">Pokaż etykietę</string>
<string name="input_layout_import_failed">Nie można zaimportować tego układu</string>
<string name="theme_white_red">Szkarłatny śnieg</string>
</resources>
+5 -8
View File
@@ -1230,7 +1230,6 @@
<string name="deck_tab_gamepad">Comando</string>
<string name="deck_surface_stick">Joystick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alternar entre joystick e trackpad</string>
<string name="deck_close">Fechar controlos do segundo ecrã</string>
<string name="deck_open_steam_menu">Abrir menu Steam</string>
<string name="deck_right_trackpad">Trackpad direito</string>
@@ -1794,13 +1793,6 @@
<string name="bottom_downloads_shader">A transferir a cache de shaders</string>
<string name="bottom_downloads_paused">Em pausa</string>
<string name="bottom_downloads_queued">A aguardar início</string>
<string name="bottom_page_notifications">Notificações</string>
<string name="bottom_notifications_listener">Ecrã inferior do DroidDeck</string>
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto joga. Ficam apenas neste ecrã e não são guardadas. O Android pergunta uma vez: a página de acesso às notificações abre neste ecrã; ative lá o DroidDeck e esta página volta automaticamente.</string>
<string name="bottom_notifications_allow">Permitir acesso às notificações</string>
<string name="bottom_notifications_none">Sem notificações.</string>
<string name="bottom_notifications_count">%1$d notificações</string>
<string name="bottom_notifications_clear">Limpar</string>
<string name="bottom_page_screenshots">Capturas de ecrã</string>
<string name="stats_fps_needs_hud">O FPS requer o HUD ativado</string>
<string name="stats_speed">Velocidade</string>
@@ -2895,6 +2887,8 @@
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
<string name="display_picture_position">Posição da imagem</string>
<string name="display_position_top">Topo</string>
<string name="display_position_left">Esquerda</string>
<string name="display_position_right">Direita</string>
<string name="display_position_bottom">Fundo</string>
<string name="display_position_center">Centro (predefinido)</string>
<string name="setup_tab_storage">Armazenamento</string>
@@ -3700,4 +3694,7 @@
<string name="input_reset_positions">Repor posições</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">Não foi possível importar este esquema</string>
<string name="theme_white_red">Neve Escarlate</string>
</resources>
+5 -8
View File
@@ -1230,7 +1230,6 @@
<string name="deck_tab_gamepad">Controle</string>
<string name="deck_surface_stick">Analógico</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alternar entre analógico e trackpad</string>
<string name="deck_close">Fechar controles da segunda tela</string>
<string name="deck_open_steam_menu">Abrir o menu Steam</string>
<string name="deck_right_trackpad">Trackpad direito</string>
@@ -1794,13 +1793,6 @@
<string name="bottom_downloads_shader">Baixando o cache de shaders</string>
<string name="bottom_downloads_paused">Pausado</string>
<string name="bottom_downloads_queued">Aguardando para começar</string>
<string name="bottom_page_notifications">Notificações</string>
<string name="bottom_notifications_listener">Tela inferior do DroidDeck</string>
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto você joga. Elas ficam só nesta tela e não são salvas. O Android pergunta uma vez: a página de acesso a notificações dele abre nesta tela; ative o DroidDeck lá e esta página volta sozinha.</string>
<string name="bottom_notifications_allow">Permitir acesso a notificações</string>
<string name="bottom_notifications_none">Nenhuma notificação.</string>
<string name="bottom_notifications_count">%1$d notificações</string>
<string name="bottom_notifications_clear">Limpar</string>
<string name="bottom_page_screenshots">Capturas de tela</string>
<string name="stats_fps_needs_hud">O FPS precisa do HUD ativado</string>
<string name="stats_speed">Velocidade</string>
@@ -2895,6 +2887,8 @@
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
<string name="display_picture_position">Posição da imagem</string>
<string name="display_position_top">Topo</string>
<string name="display_position_left">Esquerda</string>
<string name="display_position_right">Direita</string>
<string name="display_position_bottom">Base</string>
<string name="display_position_center">Centro (padrão)</string>
<string name="setup_tab_storage">Armazenamento</string>
@@ -3700,4 +3694,7 @@
<string name="input_reset_positions">Redefinir posições</string>
<string name="input_show_label">Mostrar rótulo</string>
<string name="input_layout_import_failed">Não foi possível importar este layout</string>
<string name="theme_white_red">Neve Escarlate</string>
</resources>
+5 -8
View File
@@ -1238,7 +1238,6 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Manșă</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Comută între manșă și trackpad</string>
<string name="deck_close">Închide comenzile celui de-al doilea ecran</string>
<string name="deck_open_steam_menu">Deschide meniul Steam</string>
<string name="deck_right_trackpad">Trackpad-ul drept</string>
@@ -1802,13 +1801,6 @@
<string name="bottom_downloads_shader">Se descarcă cache-ul de shadere</string>
<string name="bottom_downloads_paused">Întrerupt</string>
<string name="bottom_downloads_queued">Așteaptă să înceapă</string>
<string name="bottom_page_notifications">Notificări</string>
<string name="bottom_notifications_listener">Ecranul de jos DroidDeck</string>
<string name="bottom_notifications_why">Afișează aici mesajele și alte notificări în timp ce te joci. Rămân doar pe acest ecran și nu sunt salvate. Android întreabă o singură dată: pagina sa de acces la notificări se deschide pe acest ecran; activează DroidDeck acolo, iar această pagină revine singură.</string>
<string name="bottom_notifications_allow">Permite accesul la notificări</string>
<string name="bottom_notifications_none">Nicio notificare.</string>
<string name="bottom_notifications_count">Notificări: %1$d</string>
<string name="bottom_notifications_clear">Șterge</string>
<string name="bottom_page_screenshots">Capturi de ecran</string>
<string name="stats_fps_needs_hud">FPS necesită HUD-ul activat</string>
<string name="stats_speed">Viteză</string>
@@ -2897,6 +2889,8 @@
<string name="display_position_top">Sus</string>
<string name="display_position_bottom">Jos</string>
<string name="display_position_center">Centru (implicit)</string>
<string name="display_position_left">Stânga</string>
<string name="display_position_right">Dreapta</string>
<string name="setup_tab_storage">Stocare</string>
<string name="storage_measuring">Se măsoară stocarea…</string>
<string name="storage_measuring_hint">Prima dată durează câteva secunde.</string>
@@ -3700,4 +3694,7 @@
<string name="input_reset_positions">Resetează pozițiile</string>
<string name="input_show_label">Afișează eticheta</string>
<string name="input_layout_import_failed">Acest aranjament nu a putut fi importat</string>
<string name="theme_white_red">Zăpadă stacojie</string>
</resources>
+5 -8
View File
@@ -1282,7 +1282,6 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стик</string>
<string name="deck_surface_trackpad">Тачпад</string>
<string name="deck_surface_switch">Переключение между стиком и тачпадом</string>
<string name="deck_close">Закрыть управление вторым экраном</string>
<string name="deck_open_steam_menu">Открыть меню Steam</string>
<string name="deck_right_trackpad">Правый тачпад</string>
@@ -1889,13 +1888,6 @@
<string name="bottom_downloads_shader">Загрузка кэша шейдеров</string>
<string name="bottom_downloads_paused">Приостановлено</string>
<string name="bottom_downloads_queued">Ожидает начала</string>
<string name="bottom_page_notifications">Уведомления</string>
<string name="bottom_notifications_listener">Нижний экран DroidDeck</string>
<string name="bottom_notifications_why">Показывает сообщения и другие уведомления здесь, пока вы играете. Они отображаются только на этом экране и не сохраняются. Android спросит один раз: на этом экране откроется страница доступа к уведомлениям; включите там DroidDeck, и эта страница вернется сама.</string>
<string name="bottom_notifications_allow">Разрешить доступ к уведомлениям</string>
<string name="bottom_notifications_none">Нет уведомлений.</string>
<string name="bottom_notifications_count">Уведомлений: %1$d</string>
<string name="bottom_notifications_clear">Очистить</string>
<string name="bottom_page_screenshots">Скриншоты</string>
<string name="stats_fps_needs_hud">Для FPS нужно включить HUD</string>
<string name="stats_speed">Скорость</string>
@@ -3009,6 +3001,8 @@
<string name="display_position_top">Сверху</string>
<string name="display_position_bottom">Снизу</string>
<string name="display_position_center">По центру (по умолчанию)</string>
<string name="display_position_left">Слева</string>
<string name="display_position_right">Справа</string>
<string name="setup_tab_storage">Хранилище</string>
<string name="storage_measuring">Измерение хранилища…</string>
<string name="storage_measuring_hint">В первый раз занимает несколько секунд.</string>
@@ -3822,4 +3816,7 @@
<string name="input_reset_positions">Сбросить положение</string>
<string name="input_show_label">Показывать подпись</string>
<string name="input_layout_import_failed">Не удалось импортировать эту раскладку</string>
<string name="theme_white_red">Алый снег</string>
</resources>
+5 -8
View File
@@ -1231,7 +1231,6 @@
<string name="deck_tab_gamepad">Handkontroll</string>
<string name="deck_surface_stick">Styrspak</string>
<string name="deck_surface_trackpad">Styrplatta</string>
<string name="deck_surface_switch">Växla mellan styrspak och styrplatta</string>
<string name="deck_close">Stäng kontrollerna på andra skärmen</string>
<string name="deck_open_steam_menu">Öppna Steam-menyn</string>
<string name="deck_right_trackpad">Höger styrplatta</string>
@@ -1778,13 +1777,6 @@
<string name="bottom_downloads_shader">Laddar ned shadercache</string>
<string name="bottom_downloads_paused">Pausad</string>
<string name="bottom_downloads_queued">Väntar på att starta</string>
<string name="bottom_page_notifications">Aviseringar</string>
<string name="bottom_notifications_listener">DroidDecks nedre skärm</string>
<string name="bottom_notifications_why">Visa meddelanden och andra aviseringar här medan du spelar. De visas bara på den här skärmen och sparas inte. Android frågar en gång: sidan för aviseringsåtkomst öppnas på den här skärmen; slå på DroidDeck där så kommer den här sidan tillbaka av sig själv.</string>
<string name="bottom_notifications_allow">Tillåt aviseringsåtkomst</string>
<string name="bottom_notifications_none">Inga aviseringar.</string>
<string name="bottom_notifications_count">%1$d aviseringar</string>
<string name="bottom_notifications_clear">Rensa</string>
<string name="bottom_page_screenshots">Skärmbilder</string>
<string name="stats_fps_needs_hud">FPS kräver att HUD:en är på</string>
<string name="stats_speed">Hastighet</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Överst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Mitten (standard)</string>
<string name="display_position_left">Vänster</string>
<string name="display_position_right">Höger</string>
<string name="setup_tab_storage">Lagring</string>
<string name="storage_measuring">Mäter lagring…</string>
<string name="storage_measuring_hint">Första gången tar några sekunder.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Återställ positioner</string>
<string name="input_show_label">Visa etikett</string>
<string name="input_layout_import_failed">Det gick inte att importera layouten</string>
<string name="theme_white_red">Scharlakansröd snö</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">เกมแพด</string>
<string name="deck_surface_stick">สติ๊ก</string>
<string name="deck_surface_trackpad">แทร็กแพด</string>
<string name="deck_surface_switch">สลับระหว่างสติ๊กและแทร็กแพด</string>
<string name="deck_close">ปิดการควบคุมหน้าจอที่สอง</string>
<string name="deck_open_steam_menu">เปิดเมนู Steam</string>
<string name="deck_right_trackpad">แทร็กแพดขวา</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">กำลังดาวน์โหลดแคชเชเดอร์</string>
<string name="bottom_downloads_paused">หยุดชั่วคราว</string>
<string name="bottom_downloads_queued">กำลังรอเริ่ม</string>
<string name="bottom_page_notifications">การแจ้งเตือน</string>
<string name="bottom_notifications_listener">หน้าจอล่างของ DroidDeck</string>
<string name="bottom_notifications_why">แสดงข้อความและการแจ้งเตือนอื่นๆ ที่นี่ขณะที่คุณเล่น การแจ้งเตือนจะแสดงบนหน้าจอนี้เท่านั้นและจะไม่ถูกบันทึก Android จะถามเพียงครั้งเดียว: หน้าการเข้าถึงการแจ้งเตือนจะเปิดขึ้นบนหน้าจอนี้ เปิด DroidDeck ที่นั่น แล้วหน้านี้จะกลับมาเอง</string>
<string name="bottom_notifications_allow">อนุญาตการเข้าถึงการแจ้งเตือน</string>
<string name="bottom_notifications_none">ไม่มีการแจ้งเตือน</string>
<string name="bottom_notifications_count">การแจ้งเตือน %1$d รายการ</string>
<string name="bottom_notifications_clear">ล้าง</string>
<string name="bottom_page_screenshots">ภาพหน้าจอ</string>
<string name="stats_fps_needs_hud">ต้องเปิด HUD เพื่อแสดง FPS</string>
<string name="stats_speed">ความเร็ว</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">บน</string>
<string name="display_position_bottom">ล่าง</string>
<string name="display_position_center">กลาง (ค่าเริ่มต้น)</string>
<string name="display_position_left">ซ้าย</string>
<string name="display_position_right">ขวา</string>
<string name="setup_tab_storage">พื้นที่จัดเก็บ</string>
<string name="storage_measuring">กำลังวัดพื้นที่…</string>
<string name="storage_measuring_hint">ครั้งแรกใช้เวลาสองสามวินาที</string>
@@ -3631,4 +3625,7 @@
<string name="input_reset_positions">รีเซ็ตตำแหน่ง</string>
<string name="input_show_label">แสดงป้ายกำกับ</string>
<string name="input_layout_import_failed">ไม่สามารถนำเข้าการจัดวางนี้ได้</string>
<string name="theme_white_red">หิมะสีแดงชาด</string>
</resources>
+5 -8
View File
@@ -1223,7 +1223,6 @@
<string name="deck_tab_gamepad">Oyun kolu</string>
<string name="deck_surface_stick">Çubuk</string>
<string name="deck_surface_trackpad">Dokunmatik yüzey</string>
<string name="deck_surface_switch">Çubuk ile dokunmatik yüzey arasında geçiş yap</string>
<string name="deck_close">İkinci ekran kontrollerini kapat</string>
<string name="deck_open_steam_menu">Steam menüsünü aç</string>
<string name="deck_right_trackpad">Sağ dokunmatik yüzey</string>
@@ -1770,13 +1769,6 @@
<string name="bottom_downloads_shader">Gölgelendirici önbelleği indiriliyor</string>
<string name="bottom_downloads_paused">Duraklatıldı</string>
<string name="bottom_downloads_queued">Başlamayı bekliyor</string>
<string name="bottom_page_notifications">Bildirimler</string>
<string name="bottom_notifications_listener">DroidDeck alt ekranı</string>
<string name="bottom_notifications_why">Oynarken mesajları ve diğer bildirimleri burada görün. Yalnızca bu ekranda kalırlar ve kaydedilmezler. Android bunu bir kez sorar: bildirim erişimi sayfası bu ekranda açılır; orada DroidDeck\'i açın, bu sayfa kendiliğinden geri gelir.</string>
<string name="bottom_notifications_allow">Bildirim erişimine izin ver</string>
<string name="bottom_notifications_none">Bildirim yok.</string>
<string name="bottom_notifications_count">%1$d bildirim</string>
<string name="bottom_notifications_clear">Temizle</string>
<string name="bottom_page_screenshots">Ekran görüntüleri</string>
<string name="stats_fps_needs_hud">FPS için HUD açık olmalı</string>
<string name="stats_speed">Hız</string>
@@ -2868,6 +2860,8 @@
<string name="display_position_top">Üst</string>
<string name="display_position_bottom">Alt</string>
<string name="display_position_center">Orta (varsayılan)</string>
<string name="display_position_left">Sol</string>
<string name="display_position_right">Sağ</string>
<string name="setup_tab_storage">Depolama</string>
<string name="storage_measuring">Depolama ölçülüyor…</string>
<string name="storage_measuring_hint">İlk seferde birkaç saniye sürer.</string>
@@ -3661,4 +3655,7 @@
<string name="input_reset_positions">Konumları sıfırla</string>
<string name="input_show_label">Etiketi göster</string>
<string name="input_layout_import_failed">Bu düzen içe aktarılamadı</string>
<string name="theme_white_red">Kızıl Kar</string>
</resources>
+5 -8
View File
@@ -1245,7 +1245,6 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стік</string>
<string name="deck_surface_trackpad">Трекпад</string>
<string name="deck_surface_switch">Перемкнути між стіком і трекпадом</string>
<string name="deck_close">Закрити керування на другому екрані</string>
<string name="deck_open_steam_menu">Відкрити меню Steam</string>
<string name="deck_right_trackpad">Правий трекпад</string>
@@ -1826,13 +1825,6 @@
<string name="bottom_downloads_shader">Завантаження кешу шейдерів</string>
<string name="bottom_downloads_paused">Призупинено</string>
<string name="bottom_downloads_queued">Очікує початку</string>
<string name="bottom_page_notifications">Сповіщення</string>
<string name="bottom_notifications_listener">Нижній екран DroidDeck</string>
<string name="bottom_notifications_why">Показуйте тут повідомлення та інші сповіщення під час гри. Вони лишаються лише на цьому екрані й не зберігаються. Android запитає один раз: сторінка доступу до сповіщень відкриється на цьому екрані; увімкніть там DroidDeck, і ця сторінка повернеться сама.</string>
<string name="bottom_notifications_allow">Дозволити доступ до сповіщень</string>
<string name="bottom_notifications_none">Немає сповіщень.</string>
<string name="bottom_notifications_count">Сповіщень: %1$d</string>
<string name="bottom_notifications_clear">Очистити</string>
<string name="bottom_page_screenshots">Знімки екрана</string>
<string name="stats_fps_needs_hud">Для FPS потрібно ввімкнути HUD</string>
<string name="stats_speed">Швидкість</string>
@@ -2926,6 +2918,8 @@
<string name="display_position_top">Угорі</string>
<string name="display_position_bottom">Унизу</string>
<string name="display_position_center">По центру (типово)</string>
<string name="display_position_left">Ліворуч</string>
<string name="display_position_right">Праворуч</string>
<string name="setup_tab_storage">Сховище</string>
<string name="storage_measuring">Вимірювання сховища…</string>
<string name="storage_measuring_hint">Уперше триває кілька секунд.</string>
@@ -3739,4 +3733,7 @@
<string name="input_reset_positions">Скинути розташування</string>
<string name="input_show_label">Показувати підпис</string>
<string name="input_layout_import_failed">Не вдалося імпортувати цю розкладку</string>
<string name="theme_white_red">Багряний сніг</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">Tay cầm</string>
<string name="deck_surface_stick">Cần</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Chuyển giữa cần và trackpad</string>
<string name="deck_close">Đóng điều khiển màn hình phụ</string>
<string name="deck_open_steam_menu">Mở menu Steam</string>
<string name="deck_right_trackpad">Trackpad phải</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">Đang tải bộ nhớ đệm shader</string>
<string name="bottom_downloads_paused">Đã tạm dừng</string>
<string name="bottom_downloads_queued">Đang chờ bắt đầu</string>
<string name="bottom_page_notifications">Thông báo</string>
<string name="bottom_notifications_listener">Màn hình dưới DroidDeck</string>
<string name="bottom_notifications_why">Hiển thị tin nhắn và các thông báo khác ở đây khi bạn chơi. Chúng chỉ ở trên màn hình này và không được lưu. Android chỉ hỏi một lần: trang quyền truy cập thông báo sẽ mở trên màn hình này; hãy bật DroidDeck ở đó và trang này sẽ tự quay lại.</string>
<string name="bottom_notifications_allow">Cho phép truy cập thông báo</string>
<string name="bottom_notifications_none">Không có thông báo.</string>
<string name="bottom_notifications_count">%1$d thông báo</string>
<string name="bottom_notifications_clear">Xóa</string>
<string name="bottom_page_screenshots">Ảnh chụp màn hình</string>
<string name="stats_fps_needs_hud">Cần bật HUD để xem FPS</string>
<string name="stats_speed">Tốc độ</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">Trên</string>
<string name="display_position_bottom">Dưới</string>
<string name="display_position_center">Giữa (mặc định)</string>
<string name="display_position_left">Trái</string>
<string name="display_position_right">Phải</string>
<string name="setup_tab_storage">Lưu trữ</string>
<string name="storage_measuring">Đang đo dung lượng…</string>
<string name="storage_measuring_hint">Lần đầu mất vài giây.</string>
@@ -3631,4 +3625,7 @@
<string name="input_reset_positions">Đặt lại vị trí</string>
<string name="input_show_label">Hiện nhãn</string>
<string name="input_layout_import_failed">Không thể nhập bố cục này</string>
<string name="theme_white_red">Tuyết đỏ thẫm</string>
</resources>
+5 -8
View File
@@ -1308,7 +1308,6 @@
<string name="deck_tab_gamepad">手柄</string>
<string name="deck_surface_stick">摇杆</string>
<string name="deck_surface_trackpad">触控板</string>
<string name="deck_surface_switch">在摇杆与触控板之间切换</string>
<string name="deck_close">关闭副屏控制</string>
<string name="deck_open_steam_menu">打开 Steam 菜单</string>
<string name="deck_right_trackpad">右触控板</string>
@@ -1832,13 +1831,6 @@
<string name="bottom_downloads_shader">正在下载着色器缓存</string>
<string name="bottom_downloads_paused">已暂停</string>
<string name="bottom_downloads_queued">等待开始</string>
<string name="bottom_page_notifications">通知</string>
<string name="bottom_notifications_listener">DroidDeck 底部屏幕</string>
<string name="bottom_notifications_why">在游戏时于此处显示消息和其他通知。通知只显示在此屏幕上,不会被保存。Android 只会询问一次:其通知使用权页面会在此屏幕上打开,在那里开启 DroidDeck 后,会自动返回此页面。</string>
<string name="bottom_notifications_allow">允许通知使用权</string>
<string name="bottom_notifications_none">没有通知。</string>
<string name="bottom_notifications_count">%1$d 条通知</string>
<string name="bottom_notifications_clear">清除</string>
<string name="bottom_page_screenshots">截图</string>
<string name="stats_fps_needs_hud">显示 FPS 需要开启 HUD</string>
<string name="stats_speed">速度</string>
@@ -2843,6 +2835,8 @@
<string name="display_position_top">置顶</string>
<string name="display_position_bottom">置底</string>
<string name="display_position_center">居中(默认)</string>
<string name="display_position_left">左侧</string>
<string name="display_position_right">右侧</string>
<string name="setup_tab_storage">存储</string>
<string name="storage_measuring">正在统计存储…</string>
<string name="storage_measuring_hint">首次统计需要几秒钟。</string>
@@ -3635,4 +3629,7 @@
<string name="input_reset_positions">重置位置</string>
<string name="input_show_label">显示标签</string>
<string name="input_layout_import_failed">无法导入此布局</string>
<string name="theme_white_red">猩红之雪</string>
</resources>
+5 -8
View File
@@ -1224,7 +1224,6 @@
<string name="deck_tab_gamepad">控制器</string>
<string name="deck_surface_stick">搖桿</string>
<string name="deck_surface_trackpad">觸控板</string>
<string name="deck_surface_switch">在搖桿和觸控板之間切換</string>
<string name="deck_close">關閉第二螢幕控制</string>
<string name="deck_open_steam_menu">開啟 Steam 選單</string>
<string name="deck_right_trackpad">右側觸控板</string>
@@ -1754,13 +1753,6 @@
<string name="bottom_downloads_shader">正在下載著色器快取</string>
<string name="bottom_downloads_paused">已暫停</string>
<string name="bottom_downloads_queued">等待開始</string>
<string name="bottom_page_notifications">通知</string>
<string name="bottom_notifications_listener">DroidDeck 下方螢幕</string>
<string name="bottom_notifications_why">在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。</string>
<string name="bottom_notifications_allow">允許通知存取權</string>
<string name="bottom_notifications_none">沒有通知。</string>
<string name="bottom_notifications_count">%1$d 則通知</string>
<string name="bottom_notifications_clear">清除</string>
<string name="bottom_page_screenshots">螢幕截圖</string>
<string name="stats_fps_needs_hud">顯示 FPS 需要開啟 HUD</string>
<string name="stats_speed">速度</string>
@@ -2839,6 +2831,8 @@
<string name="display_position_top">置頂</string>
<string name="display_position_bottom">置底</string>
<string name="display_position_center">置中(預設)</string>
<string name="display_position_left">左側</string>
<string name="display_position_right">右側</string>
<string name="setup_tab_storage">儲存空間</string>
<string name="storage_measuring">正在統計儲存空間…</string>
<string name="storage_measuring_hint">首次統計需要幾秒鐘。</string>
@@ -3634,4 +3628,7 @@
<string name="input_reset_positions">重設位置</string>
<string name="input_show_label">顯示標籤</string>
<string name="input_layout_import_failed">無法匯入此佈局</string>
<string name="theme_white_red">緋紅之雪</string>
</resources>
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more