Compare commits

...
Author SHA1 Message Date
Kurt Himebauch 065d904fa9 chore(ea): log legacy entitlements when no game licence is granted 2026-11-08 22:04:58 -05:00
Kurt Himebauch b2f2978b71 chore(ea): log the account's entitlements when no game licence is granted 2026-11-08 21:23:51 -05:00
Kurt Himebauch d27144c13d chore(ea): log owned offers and the full licence refusal 2026-11-08 20:59:09 -05:00
Kurt Himebauch c3ddc3f69c Merge origin/main into feat/ea-store 2026-11-08 20:37:55 -05:00
Kurt Himebauch 7ecfbf7478 fix(ea): record why EA refuses a game licence in the store log 2026-11-08 20:32:29 -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 8bdff5763b Merge remote-tracking branch 'origin/main' into feat/ea-store 2026-11-08 17:58:54 -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
Kurt Himebauch ab0b1ad371 fix(ea): LSX restarts rebind its listener and promotion checks the launch generation 2026-11-08 02:20:00 -05:00
Kurt Himebauch 68d9fbcefb fix(ea): LSX publishes launch state under the admission lock and tags accepts by generation 2026-11-08 02:13:52 -05:00
Kurt Himebauch 7ed7a46b8f fix(ea): LSX launch generation guards restarts and drops results after shutdown 2026-11-08 02:06:58 -05:00
Kurt Himebauch eac510d1d7 fix(ea): LSX trust promotion excludes eviction 2026-11-08 02:01:48 -05:00
Kurt Himebauch bcf33685ec fix(ea): LSX admission and eviction share one lock and keep the cap 2026-11-08 01:55:25 -05:00
Kurt Himebauch c3bcfc8593 fix(ea): LSX evicts each untrusted session at most once 2026-11-08 01:49:54 -05:00
Kurt Himebauch c27c6a336a fix(ea): LSX never registers a client after its listener was stopped 2026-11-08 01:43:58 -05:00
Kurt Himebauch db81d233b0 fix(ea): LSX handshake has a fixed deadline and untrusted sessions yield their slot 2026-11-08 01:42:56 -05:00
Kurt Himebauch e7d11a5feb fix(ea): LSX hides profile and presence from unverified peers 2026-11-08 01:32:03 -05:00
Kurt Himebauch e7eed182b4 fix(ea): LSX trusts only verified peers, new launch closes old sessions, exit stops its own game 2026-11-08 01:29:38 -05:00
Kurt Himebauch 4f6a1fcd17 fix(ea): serialize sign-out with token refresh and with the library cache 2026-11-08 01:15:09 -05:00
Kurt Himebauch a68429fcc4 fix(ea): clear the sign-in before invalidating the library 2026-11-08 01:07:27 -05:00
Kurt Himebauch 4605b4a756 fix(ea): hand off library sync requests under one lock 2026-11-08 01:01:26 -05:00
Kurt Himebauch 942dc1d587 fix(ea): serialize the library cache write with sign-out and rerun a skipped sync 2026-11-08 00:55:52 -05:00
Kurt Himebauch 56911e9f25 fix(ea): trust one LSX session per launch 2026-11-08 00:54:40 -05:00
Kurt Himebauch a4aa19d2ac fix(ea): require every hashed field before licensing or caching a probe 2026-11-08 00:53:27 -05:00
Kurt Himebauch 94a7330491 fix(ea): close LSX sessions on stop and drop clients that never hand-shake 2026-11-08 00:51:37 -05:00
Kurt Himebauch 35b77fdbf5 fix(ea): trust only the prepared game on LSX and stop stale library syncs 2026-11-08 00:46:08 -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
Kurt Himebauch fc0e0b04cf fix(ea): run legacy Origin SDK games with a stand-in Origin process 2026-11-08 00:30:27 -05:00
Kurt Himebauch afb23680f7 fix(ea): license legacy EA Core games and harden the LSX endpoint 2026-11-08 00:05:03 -05:00
Kurt Himebauch 95511dc5c0 fix(ea): register game display names 2026-11-07 23:28:42 -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
Kurt Himebauch 228a1311ae perf(ea): fetch large archive records as parallel ranges 2026-11-07 22:56:57 -05:00
Kurt Himebauch b67bdcd0b3 feat(ea): EA store with sign-in, library, native downloads, updates and launch without the EA app 2026-11-07 22:41:12 -05:00
Kurt Himebauch df265ea3a1 docs: research and plan for EA and Ubisoft stores 2026-11-07 22:06: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
185 changed files with 7917 additions and 261 deletions

No files matched your search

+2
View File
@@ -10,3 +10,5 @@ tools/mangoapp/mangoapp text eol=lf
app/src/main/assets/desktop-removal/*.json text eol=lf
app/src/main/assets/desktop-removal/*.txt text eol=lf
*.pdf binary
app/src/main/resources/ea/graphql/*.gql -text
+10 -3
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
+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"
+4 -2
View File
@@ -7,7 +7,7 @@
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>
@@ -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).
@@ -92,6 +93,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- Epic Games and GOG build on [Phobos](https://github.com/phobos665)'s adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl). GOG install script registry entries are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) (GPL-3.0) reads them.
- Epic's official Linux launcher preview runs through FEX. That setup is outside Epic's listed Linux support.
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
- The EA store follows the EA app's protocols as documented by [ArmchairDevelopers](https://github.com/ArmchairDevelopers)' [Maxima](https://github.com/ArmchairDevelopers/Maxima) (GPL-3.0): sign-in, the GraphQL queries (included verbatim, since EA matches them by hash), build archives, online activation licences and the LSX game endpoint. The code is DroidDeck's own.
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
+2 -2
View File
@@ -131,8 +131,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 17
versionName "0.6.9"
versionCode 18
versionName "0.7.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -61,6 +61,8 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
com.droiddeck.launcher.sync.Syncthing.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
com.droiddeck.launcher.stores.ea.EaBackend.app = this
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
}
private fun prefetchAddonPacks() {
@@ -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
@@ -178,6 +178,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
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
@@ -565,6 +568,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()
@@ -977,7 +981,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 +1226,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 +1447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
},
onClose = { drawerOpen = false },
onDismissScreen = if (docked) ({ selectSecondScreenMode(SecondScreenMode.NONE) }) else null,
components = drawerComponents,
onComponentsRefresh = {
refreshDrawerComponents()
@@ -2225,6 +2230,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 +2321,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) {
@@ -2826,6 +2834,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
}
@@ -3378,6 +3387,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)
@@ -3518,8 +3531,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 +3550,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 +3564,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 +3609,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 +3620,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 +3638,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? {
@@ -3918,9 +3974,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 +3996,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 +4024,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 +4100,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 +4220,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 +4313,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 +4328,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 +4371,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 +4380,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 +4393,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 +5143,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(
@@ -5390,6 +5464,7 @@ 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
@@ -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)
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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,
@@ -40,6 +40,7 @@ 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-msi-install" to "usr/local/bin/droiddeck-msi-install",
@@ -112,6 +113,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",
@@ -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))
@@ -87,7 +87,7 @@ object CloudSaves {
val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
}
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> ""
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> ""
} ?: return null
p.edit()
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
@@ -148,7 +148,7 @@ object CloudSaves {
val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
}
return dir to (if (dir == null) "unresolved" else "ok")
}
@@ -173,7 +173,7 @@ object CloudSaves {
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
}
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
}
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
@@ -13,11 +13,13 @@ internal object LoginFlows {
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
Store.EA -> eaFlow(browserUserAgent)
Store.ITCH, Store.XBOX, Store.BATTLENET -> null
}
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null }
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
private class GogFlow(private val state: String, override val userAgent: String) : StoreLoginActivity.Flow {
@@ -7,7 +7,8 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
XBOX("xbox", "Xbox", "Xbox"),
ITCH("itch", "Itch.io", "Itch.io"),
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
BATTLENET("battlenet", "Battle.net", "Battle.net");
BATTLENET("battlenet", "Battle.net", "Battle.net"),
EA("ea", "EA", "EA");
val folder: String get() = if (this == ITCH) "itch" else shortLabel
@@ -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,
@@ -11,9 +11,12 @@ object StoreLaunch {
const val LAUNCHER = ".droiddeck-launch.bat"
const val EPIC_CODE = ".droiddeck-epic-code"
const val EPIC_ARGS = ".droiddeck-epic-args"
const val EA_ENV = ".droiddeck-ea-env"
/** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */
const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe"
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA
val file = File(folder, LAUNCHER)
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
file.writeText(launcherText(sidecar))
@@ -49,6 +52,14 @@ object StoreLaunch {
lines += "del \"%~dp0$EPIC_ARGS\""
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%"
} else if (sidecar.store == Store.EA) {
lines += "if not exist \"%~dp0$EA_ENV\" goto run"
lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\""
lines += "del \"%~dp0$EA_ENV\""
lines += ":run"
lines += "if exist \"$EA_ORIGIN\" start \"\" \"$EA_ORIGIN\""
lines += run
lines += "taskkill /f /im Origin.exe >nul 2>&1"
} else {
lines += run
}
@@ -38,6 +38,7 @@ object StoreLaunchRequests {
fun stop() {
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
com.droiddeck.launcher.stores.ea.EaLsx.stop()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -58,6 +59,12 @@ object StoreLaunchRequests {
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
"ea-prepare" -> runCatching { lastLaunched = Store.EA to request.optString("id"); com.droiddeck.launcher.stores.ea.EaLaunch.prepare(app, request) }
.getOrElse { JSONObject().put("ok", false).put("reason", "error-" + it.javaClass.simpleName) }
"ea-exited" -> {
com.droiddeck.launcher.stores.ea.EaLsx.stop(request.optString("id"))
JSONObject().put("ok", true)
}
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
"epic-code" -> {
@@ -55,6 +55,7 @@ object StoreSizes {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.ITCH, Store.GAMEJOLT -> {}
Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.GOG, Store.XBOX, Store.BATTLENET -> {}
}
}
@@ -111,6 +112,7 @@ object StoreSizes {
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
Store.GOG, Store.XBOX, Store.BATTLENET -> 0L
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
@@ -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 {
@@ -0,0 +1,224 @@
package com.droiddeck.launcher.stores.ea
import android.net.Uri
import org.json.JSONArray
import org.json.JSONObject
class EaApiException(val status: Int, message: String) : Exception(message)
object EaApi {
private const val GRAPHQL = "https://service-aggregation-layer.juno.ea.com/graphql"
private const val LOCALE = "DEFAULT"
class Player(val userId: String, val personaId: String, val displayName: String, val eaId: String)
class OwnedProduct(
val offerId: String,
val productId: String,
val name: String,
val slug: String,
val baseSlug: String,
val gameType: String,
val downloadable: Boolean,
val trial: Boolean,
val ownership: List<String>,
)
class Offer(
val offerId: String,
val contentId: String,
val displayName: String,
val displayType: String,
val downloadable: Boolean,
val executePathOverride: String,
val installCheckOverride: String,
val executeParameters: String,
val multiplayerId: String,
val masterTitleId: String,
val gameVersion: String,
val cloudSaves: String,
val locales: List<String>,
)
class Images(val tall: String?, val wide: String?, val logo: String?)
class Build(val buildId: String, val gameVersion: String, val liveDate: String)
private val queries = HashMap<String, String>()
internal fun query(name: String): String = synchronized(queries) {
queries.getOrPut(name) {
val stream = EaApi::class.java.getResourceAsStream("/ea/graphql/$name.gql") ?: throw IllegalStateException("missing query $name")
stream.use { it.readBytes().toString(Charsets.UTF_8) }
}
}
private fun persisted(name: String): String =
JSONObject().put("persistedQuery", JSONObject().put("version", 1).put("sha256Hash", EaCrypto.hex(EaCrypto.sha256(query(name).toByteArray())))).toString()
internal fun call(token: String, name: String, variables: JSONObject): JSONObject {
val headers = mapOf("Authorization" to "Bearer $token", "Accept" to "application/json")
val get = Uri.parse(GRAPHQL).buildUpon()
.appendQueryParameter("extensions", persisted(name))
.appendQueryParameter("operationName", name)
.appendQueryParameter("variables", variables.toString())
.build().toString()
var reply = EaHttp.request(get, headers = headers)
var json = runCatching { JSONObject(reply.text) }.getOrNull()
if (!reply.ok || json == null || json.has("errors")) {
val body = JSONObject()
.put("extensions", JSONObject(persisted(name)))
.put("variables", variables)
.put("operationName", name)
.put("query", query(name))
reply = EaHttp.request(GRAPHQL, "POST", headers + ("Content-Type" to "application/json"), body.toString().toByteArray())
json = runCatching { JSONObject(reply.text) }.getOrNull()
}
if (reply.status == 401) throw EaApiException(401, "EA refused the session")
if (json == null) throw EaApiException(reply.status, "EA gave no answer to $name (${reply.status})")
json.optJSONArray("errors")?.optJSONObject(0)?.let { throw EaApiException(reply.status, "$name: ${it.optString("message")}") }
return json.optJSONObject("data") ?: throw EaApiException(reply.status, "$name returned no data")
}
fun player(token: String): Player {
val me = call(token, "GetUserPlayer", JSONObject()).optJSONObject("me") ?: throw EaApiException(0, "no player")
val p = me.optJSONObject("player")
return Player(
userId = me.optString("pd"),
personaId = p?.optString("psd").orEmpty(),
displayName = p?.optString("displayName").orEmpty(),
eaId = p?.optString("uniqueName").orEmpty().ifEmpty { p?.optString("displayName").orEmpty() },
)
}
internal fun parseOwned(page: JSONObject): Pair<List<OwnedProduct>, String?> {
val owned = page.optJSONObject("me")?.optJSONObject("ownedGameProducts") ?: return emptyList<OwnedProduct>() to null
val items = owned.optJSONArray("items") ?: JSONArray()
val list = (0 until items.length()).mapNotNull { items.optJSONObject(it) }.mapNotNull { o ->
val product = o.optJSONObject("product") ?: return@mapNotNull null
val base = product.optJSONObject("baseItem")
val user = product.optJSONObject("gameProductUser")
val methods = user?.optJSONArray("ownershipMethods")
OwnedProduct(
offerId = o.optString("originOfferId").ifEmpty { return@mapNotNull null },
productId = product.optString("id"),
name = (base?.optString("title")?.takeIf { it.isNotBlank() } ?: product.optString("name")).replace("\n", " ").trim(),
slug = product.optString("gameSlug"),
baseSlug = base?.optString("baseGameSlug").orEmpty().takeIf { it != "null" }.orEmpty(),
gameType = base?.optString("gameType").orEmpty(),
downloadable = product.optBoolean("downloadable", true),
trial = product.optJSONObject("trialDetails")?.optString("trialType").orEmpty().let { it.isNotEmpty() && it != "null" },
ownership = (0 until (methods?.length() ?: 0)).map { methods!!.optString(it) },
)
}
val next = owned.optString("next").takeIf { it.isNotBlank() && it != "null" }
return list to next
}
fun owned(token: String): List<OwnedProduct> {
val out = ArrayList<OwnedProduct>()
var next: String? = "0"
var guard = 0
while (next != null && guard++ < 50) {
val variables = JSONObject()
.put("isMac", false)
.put("limit", 100)
.put("next", next)
.put("type", JSONArray().put("DIGITAL_FULL_GAME").put("PACKAGED_FULL_GAME"))
.put("storefronts", JSONArray().put("EA"))
.put("platforms", JSONArray().put("PC"))
val (items, after) = parseOwned(call(token, "getPreloadedOwnedGames", variables))
out += items
next = after?.takeIf { it != next && items.isNotEmpty() }
}
return out
}
internal fun parseOffers(data: JSONObject): List<Offer> {
val arr = data.optJSONArray("legacyOffers") ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.mapNotNull { o ->
val downloads = o.optJSONArray("downloads")
val version = (0 until (downloads?.length() ?: 0)).mapNotNull { downloads!!.optJSONObject(it) }
.firstOrNull { it.optString("downloadType").equals("LIVE", true) }?.optString("gameVersion")
?: downloads?.optJSONObject(0)?.optString("gameVersion")
val locales = o.optJSONArray("softwareLocales")
fun s(k: String) = o.optString(k).takeIf { it != "null" }.orEmpty()
Offer(
offerId = s("offerId").ifEmpty { return@mapNotNull null },
contentId = s("contentId"),
displayName = s("displayName"),
displayType = s("displayType"),
downloadable = o.optBoolean("isDownloadable", true),
executePathOverride = s("executePathOverride"),
installCheckOverride = s("installCheckOverride"),
executeParameters = s("executeParameters"),
multiplayerId = s("multiplayerId"),
masterTitleId = s("primaryMasterTitleId"),
gameVersion = version.orEmpty().takeIf { it != "null" }.orEmpty(),
cloudSaves = s("cloudSaveConfigurationOverride"),
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
)
}
}
fun offers(token: String, ids: List<String>): List<Offer> = ids.distinct().chunked(50).flatMap { chunk ->
parseOffers(call(token, "getLegacyCatalogDefs", JSONObject().put("offerIds", JSONArray(chunk))))
}
private fun path(o: JSONObject?, vararg keys: String): String? {
var cur = o ?: return null
for (k in keys.dropLast(1)) cur = cur.optJSONObject(k) ?: return null
return cur.optString(keys.last()).takeIf { it.startsWith("http") }
}
internal fun parseImages(data: JSONObject): Images {
val game = data.optJSONObject("game")
val key = game?.optJSONObject("keyArt")
val pack = game?.optJSONObject("packArt")
return Images(
tall = path(pack, "aspect9x16Image", "path") ?: path(key, "aspect9x16Image", "path"),
wide = path(key, "aspect16x9Image", "path") ?: path(key, "largestImage", "path") ?: path(pack, "aspect2x1Image", "path"),
logo = path(game, "primaryLogo", "largestImage", "path") ?: path(game, "primaryLogo", "path"),
)
}
fun images(token: String, slug: String): Images =
parseImages(call(token, "GameImages", JSONObject().put("gameSlug", slug).put("locale", LOCALE)))
fun downloadUrl(token: String, offerId: String, buildId: String? = null): String {
val data = call(token, "downloadUrl", JSONObject().put("offerId", offerId).put("buildId", buildId.orEmpty()))
return data.optJSONObject("downloadUrl")?.optString("url").orEmpty().ifEmpty { throw EaApiException(0, "EA gave no download link") }
}
fun builds(token: String, offerId: String): List<Build> {
val arr = call(token, "availableBuilds", JSONObject().put("offerId", offerId)).optJSONArray("availableBuilds") ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map {
Build(it.optString("buildId"), it.optString("gameVersion").takeIf { v -> v != "null" }.orEmpty(), it.optString("buildLiveDate"))
}
}
fun legacyEntitlements(token: String): List<JSONObject> {
val out = ArrayList<JSONObject>()
var next: String? = "0"
var guard = 0
while (next != null && guard++ < 50) {
val variables = JSONObject().put("limit", 100).put("next", next).put("storeFronts", JSONArray().put("EA"))
val page = call(token, "getLegacyEntitlements", variables).optJSONObject("me")?.optJSONObject("legacyEntitlements") ?: break
val items = page.optJSONArray("items") ?: JSONArray()
for (i in 0 until items.length()) items.optJSONObject(i)?.let(out::add)
next = page.optString("next").takeIf { it.isNotBlank() && it != "null" && it != next && items.length() > 0 }
}
return out
}
fun entitlements(token: String, group: String): JSONArray {
val variables = JSONObject()
.put("productIds", JSONArray())
.put("includeChildGroups", false)
.put("groupNames", JSONArray().put(group))
.put("entitlementTag", "")
.put("pageSize", 100)
.put("pageNumber", 1)
return call(token, "sdkEntitlements", variables).optJSONObject("me")?.optJSONObject("sdkEntitlements")?.optJSONArray("entitlements") ?: JSONArray()
}
}
@@ -0,0 +1,230 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONObject
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
object EaAuth {
private const val TAG = "EaAuth"
const val CLIENT_ID = "JUNO_PC_CLIENT"
private const val CLIENT_SECRET = "4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te"
const val AUTH_URL = "https://accounts.ea.com/connect/auth"
private const val TOKEN_URL = "https://accounts.ea.com/connect/token"
const val REDIRECT = "qrc:///html/login_successful.html"
private const val REFRESH_MARGIN_MS = 5 * 60 * 1000L
private val refreshLock = Any()
private val random = SecureRandom()
private val PC_SIGN_KEYS = mapOf(
"v1" to "ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(),
"v2" to "nt5FfJbdPzNcl2pkC3zgjO43Knvscxft".toByteArray(),
)
val OPAQUE_SCOPES = listOf(
"basic.commerce.cartv2", "service.atom", "dp.client.default", "signin", "social_recommendation_user",
"basic.optin.write", "basic.commerce.cartv2.write", "basic.billing", "external.social_information_ups_admin",
)
class Account(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long,
val userId: String,
val personaId: String,
val displayName: String,
val eaId: String,
)
class Pending(val url: String, val verifier: String)
internal fun fromJson(o: JSONObject): Account? {
val access = o.optString("access_token").ifEmpty { return null }
return Account(
accessToken = access,
refreshToken = o.optString("refresh_token"),
expiresAt = o.optLong("expires_at"),
userId = o.optString("user_id"),
personaId = o.optString("persona_id"),
displayName = o.optString("display_name"),
eaId = o.optString("ea_id"),
)
}
fun load(context: Context): Account? = StoreAccounts.read(context.applicationContext, Store.EA)?.let(::fromJson)
private fun deviceId(context: Context): String {
val prefs = EaPrefs.get(context)
prefs.getString("device", null)?.let { return it }
val made = EaCrypto.hex(ByteArray(16).also(random::nextBytes))
prefs.edit().putString("device", made).apply()
return made
}
internal fun pcSign(device: String, now: Date = Date(), version: String = if (random.nextBoolean()) "v1" else "v2"): String {
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now)
val mid = EaCrypto.fnv1a(("DroidDeck" + device.uppercase() + "DroidDeck").toByteArray()).toString()
val json = "{\"av\":\"v1\",\"bsn\":\"None\",\"gid\":0,\"hsn\":\"None\",\"mid\":\"$mid\",\"msn\":\"${device.uppercase()}\",\"sv\":\"$version\",\"ts\":\"$stamp\"}"
val payload = EaCrypto.base64Url(json.toByteArray())
val signature = EaCrypto.base64Url(EaCrypto.hmacSha256(PC_SIGN_KEYS.getValue(version), payload.toByteArray()))
return "$payload.$signature"
}
internal fun authUrl(
device: String,
responseType: String,
challenge: String? = null,
accessToken: String? = null,
clientId: String = CLIENT_ID,
scopes: List<String> = emptyList(),
tokenFormat: String? = null,
expiresIn: Int? = null,
): String {
val b = Uri.parse(AUTH_URL).buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("sbiod_enabled", "false")
.appendQueryParameter("response_type", responseType)
.appendQueryParameter("locale", "en_US")
.appendQueryParameter("pc_sign", pcSign(device))
.appendQueryParameter("nonce", random.nextInt().toString())
if (scopes.isNotEmpty()) b.appendQueryParameter("scope", scopes.joinToString(" "))
if (challenge != null) {
b.appendQueryParameter("code_challenge_method", "S256")
b.appendQueryParameter("code_challenge", challenge)
}
if (accessToken != null) b.appendQueryParameter("access_token", accessToken)
if (tokenFormat != null) b.appendQueryParameter("token_format", tokenFormat)
if (expiresIn != null) b.appendQueryParameter("expires_in", expiresIn.toString())
return b.build().toString()
}
fun begin(context: Context): Pending {
val verifier = EaCrypto.base64Url(ByteArray(32).also(random::nextBytes))
val challenge = EaCrypto.base64Url(EaCrypto.sha256(verifier.toByteArray()))
return Pending(authUrl(deviceId(context), "code", challenge = challenge), verifier)
}
/** Pulls one parameter out of the redirect EA sends to its desktop client's local page. */
internal fun redirectValue(location: String, name: String): String? {
val normal = location.replace("qrc:///html", "http://127.0.0.1").replace("qrc:/html", "http://127.0.0.1")
val uri = Uri.parse(normal)
uri.getQueryParameter(name)?.let { return it }
val fragment = uri.encodedFragment ?: return null
return Uri.parse("http://x/?$fragment").getQueryParameter(name)
}
private fun tokenCall(values: List<Pair<String, String>>): JSONObject {
val reply = EaHttp.request(
TOKEN_URL, "POST",
mapOf("Content-Type" to "application/x-www-form-urlencoded"),
EaHttp.form(values + listOf("client_id" to CLIENT_ID, "client_secret" to CLIENT_SECRET)).toByteArray(),
follow = false,
)
val json = runCatching { JSONObject(reply.text) }.getOrNull()
if (!reply.ok || json == null || json.optString("access_token").isEmpty()) {
throw IllegalStateException("EA token request failed (${reply.status}): ${json?.optString("error_description")?.ifEmpty { json.optString("error") } ?: ""}")
}
return json
}
fun exchange(context: Context, code: String, verifier: String) {
val token = tokenCall(
listOf(
"grant_type" to "authorization_code", "code" to code, "code_verifier" to verifier,
"redirect_uri" to REDIRECT, "token_format" to "JWS",
),
)
val access = token.getString("access_token")
val player = runCatching { EaApi.player(access) }.onFailure { Log.w(TAG, "player: ${it.message}") }.getOrNull()
val record = JSONObject()
.put("access_token", access)
.put("refresh_token", token.optString("refresh_token"))
.put("expires_at", System.currentTimeMillis() + token.optLong("expires_in", 3600) * 1000)
.put("user_id", player?.userId.orEmpty())
.put("persona_id", player?.personaId.orEmpty())
.put("display_name", player?.displayName.orEmpty())
.put("ea_id", player?.eaId.orEmpty())
StoreAccounts.write(context.applicationContext, Store.EA, record)
}
private fun fresh(a: Account) = a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty()
private fun refreshLocked(context: Context, a: Account): String? {
if (a.refreshToken.isEmpty()) return null
val token = try {
tokenCall(listOf("grant_type" to "refresh_token", "refresh_token" to a.refreshToken))
} catch (e: Exception) {
Log.w(TAG, "refresh: ${e.message}")
return null
}
val access = token.getString("access_token")
val player = if (load(context)?.userId.isNullOrEmpty()) runCatching { EaApi.player(access) }.getOrNull() else null
return access.takeIf { storeRefreshed(context, access, token.optLong("expires_in", 3600), token.optString("refresh_token"), player) }
}
/** Writes a refreshed token into the saved sign-in, unless sign-out removed it meanwhile. */
internal fun storeRefreshed(context: Context, access: String, expiresIn: Long, refresh: String, player: EaApi.Player? = null): Boolean =
synchronized(refreshLock) {
val app = context.applicationContext
val record = StoreAccounts.read(app, Store.EA) ?: return false
record.put("access_token", access).put("expires_at", System.currentTimeMillis() + expiresIn * 1000)
refresh.takeIf { it.isNotEmpty() }?.let { record.put("refresh_token", it) }
player?.let { record.put("user_id", it.userId).put("persona_id", it.personaId).put("display_name", it.displayName).put("ea_id", it.eaId) }
StoreAccounts.write(app, Store.EA, record)
true
}
fun accessToken(context: Context): String? {
val current = load(context) ?: return null
if (fresh(current)) return current.accessToken
synchronized(refreshLock) {
val again = load(context) ?: return null
if (fresh(again)) return again.accessToken
return refreshLocked(context, again) ?: again.accessToken.takeIf { again.expiresAt > System.currentTimeMillis() }
}
}
fun refreshNow(context: Context, stale: String?): String? = synchronized(refreshLock) {
val current = load(context) ?: return null
if (stale != null && current.accessToken != stale) return current.accessToken
refreshLocked(context, current)
}
/** Asks EA to mint a code or token for another client from the signed-in session, as the EA app does for games. */
fun mint(
context: Context,
clientId: String,
responseType: String,
scopes: List<String> = emptyList(),
tokenFormat: String? = null,
expiresIn: Int? = null,
): String? {
val access = accessToken(context) ?: return null
val url = authUrl(deviceId(context), responseType, accessToken = access, clientId = clientId, scopes = scopes, tokenFormat = tokenFormat, expiresIn = expiresIn)
val reply = runCatching { EaHttp.request(url, follow = false) }.getOrElse {
Log.w(TAG, "mint $clientId: ${it.javaClass.simpleName}")
return null
}
val location = reply.header("Location") ?: run {
Log.w(TAG, "mint $clientId: no redirect (${reply.status})")
return null
}
if (location.startsWith("https://signin.ea.com")) {
Log.w(TAG, "mint $clientId: the session needs a new sign-in")
return null
}
return redirectValue(location, if (responseType == "token") "access_token" else responseType)
}
/** Waits for any refresh in progress, so it cannot write the sign-in back afterwards. */
fun signOut(context: Context) {
synchronized(refreshLock) { StoreAccounts.clear(context.applicationContext, Store.EA) }
}
}
@@ -0,0 +1,131 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.content.Intent
import android.util.Log
import android.webkit.WebView
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.WebCookies
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
object EaBackend : StoreBackend {
override val store: Store = Store.EA
@Volatile internal var app: Context? = null
init {
LoginFlows.eaFlow = { ua -> app?.let { EaLogin(EaAuth.begin(it), ua) } }
}
override fun signIn(context: Context) {
app = context.applicationContext
context.startActivity(
Intent(context, StoreLoginActivity::class.java)
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun signOut(context: Context) {
val app = context.applicationContext
EaAuth.signOut(app)
EaLibrary.reset(app)
EaUpdates.available.clear()
runCatching { WebCookies.expire(EaLogin.SIGN_IN_URLS, ".ea.com", listOf("/")) {} }
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
this.app = app
Thread({ EaLibrary.sync(app, force) }, "ea-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
if (!EaNative.ready()) {
StoresState.logLine(EaNative.ENGINE_MISSING)
StoresState.post { StoresState.problems[store] = EaNative.ENGINE_MISSING }
return
}
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { EaInstaller(app, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
}
internal class EaLogin(private val pending: EaAuth.Pending, override val userAgent: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = pending.url
override fun isRedirect(url: String): Boolean = url.startsWith("qrc:", ignoreCase = true)
override fun forgetSignIn(done: () -> Unit) {
runCatching { WebCookies.expire(SIGN_IN_URLS, ".ea.com", listOf("/")) { done() } }.onFailure { done() }
}
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val code = EaAuth.redirectValue(url, "code") ?: return activity.getString(R.string.stores_login_error_verification)
return try {
EaAuth.exchange(activity, code, pending.verifier)
null
} catch (e: Exception) {
Log.w("EaLogin", "sign-in failed: ${e.message}")
activity.getString(R.string.stores_login_error_generic)
}
}
companion object {
val SIGN_IN_URLS = listOf("https://accounts.ea.com", "https://signin.ea.com", "https://www.ea.com")
}
}
object EaUpdates {
val available = mutableStateMapOf<String, String>()
private val checked = java.util.concurrent.ConcurrentHashMap<String, Long>()
private const val RECHECK_MS = 30 * 60 * 1000L
fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) {
val app = context.applicationContext
val id = game.sidecar.id
val now = System.currentTimeMillis()
if (!force && now - (checked[id] ?: 0L) < RECHECK_MS) return
checked[id] = now
Thread({
val token = EaAuth.accessToken(app)
val offer = token?.let { runCatching { EaApi.offers(it, listOf(id)) }.getOrNull() }?.firstOrNull()
if (offer == null) {
checked.remove(id)
return@Thread
}
val live = offer.gameVersion
val installed = game.sidecar.installVersion
StoresState.post {
if (live.isNotEmpty() && installed.isNotEmpty() && live != installed) available[game.key] = live else available.remove(game.key)
}
}, "ea-update-check").start()
}
fun start(context: Context, game: InstalledStoreGame) {
val app = context.applicationContext
val root = game.folder.parentFile?.parentFile ?: return
val known = EaLibrary.game(app, game.sidecar.id)
val item = known?.let { EaLibrary.toCatalogItem(app, it) } ?: CatalogItem(
Store.EA, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, owned = true, extra = game.sidecar.extra,
)
available.remove(game.key)
StoresState.install(app, item, root)
}
}
@@ -0,0 +1,83 @@
package com.droiddeck.launcher.stores.ea
import java.security.MessageDigest
import java.time.LocalDate
import java.time.ZoneOffset
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
internal object EaCrypto {
val LSX_DEFAULT_KEY = ByteArray(16) { it.toByte() }
private val OOA_KEY = intArrayOf(65, 50, 114, 45, 208, 130, 239, 176, 220, 100, 87, 197, 118, 104, 202, 9)
.map { it.toByte() }.toByteArray()
private const val PRIME_10K = 104729L
private const val PRIME_20K = 224737L
private const val PRIME_30K = 350377L
fun ecbEncrypt(data: ByteArray, key: ByteArray): ByteArray =
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
fun ecbDecrypt(data: ByteArray, key: ByteArray): ByteArray =
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
fun licenseEncrypt(data: ByteArray): ByteArray =
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
fun licenseDecrypt(data: ByteArray): ByteArray =
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
fun unhex(text: String): ByteArray {
val digits = text.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
if (digits.length % 2 != 0) return ByteArray(0)
return ByteArray(digits.length / 2) { i -> digits.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
}
/** The session key both sides derive after the LSX challenge, from a seed the game picks. */
fun lsxKey(seed: Int): ByteArray {
if (seed == 0) return LSX_DEFAULT_KEY.copyOf()
var state = 7L
fun next(): Int {
state = (state * 214013L + 2531011L) and 0xFFFFFFFFL
return ((state ushr 16) and 0xFFFF).toInt()
}
val start = (next().toLong() + seed) and 0xFFFFFFFFL
state = start
return ByteArray(16) { next().toByte() }
}
fun challengeAnswer(key: String): String = hex(ecbEncrypt(key.toByteArray(Charsets.US_ASCII), LSX_DEFAULT_KEY))
fun challengeSeed(version: String, answer: String): Int? = when (version) {
"2" -> 0
"3" -> (answer[0].code shl 8) or answer[1].code
else -> null
}
/** EA's launch handshake code, which changes every UTC day. */
fun rtpCode(day: LocalDate = LocalDate.now(ZoneOffset.UTC)): Long {
val time = ((PRIME_10K * day.year) xor (day.monthValue * PRIME_20K) xor (day.dayOfMonth * PRIME_30K)) and 0xFFFFFFFFL
return (time xor ((time shl 16) and 0xFFFFFFFFL) xor (time ushr 16)) and 0xFFFFFFFFL
}
fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)
fun sha1(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-1").digest(data)
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) }
fun fnv1a(data: ByteArray): ULong {
var hash = 0xcbf29ce484222325UL
for (b in data) hash = (hash xor (b.toUByte().toULong())) * 0x100000001b3UL
return hash
}
fun base64Url(data: ByteArray): String =
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(data)
}
@@ -0,0 +1,47 @@
package com.droiddeck.launcher.stores.ea
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
internal class EaReply(val status: Int, val body: ByteArray, val headers: Map<String, List<String>>) {
val ok: Boolean get() = status in 200..299
val text: String get() = body.toString(Charsets.UTF_8)
fun header(name: String): String? = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull()
}
internal object EaHttp {
const val USER_AGENT = "Mozilla/5.0 EA Desktop/13.735.2.6250"
private const val TIMEOUT = 30_000
fun form(values: List<Pair<String, String>>): String =
values.joinToString("&") { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
fun request(
url: String,
method: String = "GET",
headers: Map<String, String> = emptyMap(),
body: ByteArray? = null,
follow: Boolean = true,
): EaReply {
val c = URL(url).openConnection() as HttpURLConnection
try {
c.requestMethod = method
c.connectTimeout = TIMEOUT
c.readTimeout = TIMEOUT
c.instanceFollowRedirects = follow
c.setRequestProperty("User-Agent", USER_AGENT)
for ((k, v) in headers) c.setRequestProperty(k, v)
if (body != null) {
c.doOutput = true
c.outputStream.use { it.write(body) }
}
val status = c.responseCode
val stream = if (status >= 400) c.errorStream else c.inputStream
val bytes = stream?.use { it.readBytes() } ?: ByteArray(0)
return EaReply(status, bytes, c.headerFields.filterKeys { it != null })
} finally {
c.disconnect()
}
}
}
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.amazon.AmazonNative
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
internal class EaInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
var token = EaAuth.accessToken(app) ?: throw IllegalStateException("Not signed in to EA")
val url = try {
EaApi.downloadUrl(token, item.id)
} catch (e: EaApiException) {
if (e.status != 401) throw IllegalStateException(e.message)
token = EaAuth.refreshNow(app, token) ?: throw IllegalStateException("The EA sign-in has expired")
EaApi.downloadUrl(token, item.id)
}
val game = EaLibrary.game(app, item.id)
val version = runCatching { EaApi.offers(token, listOf(item.id)).firstOrNull()?.gameVersion }.getOrNull().orEmpty()
.ifEmpty { game?.version.orEmpty() }
val cover = item.tallImageUrl ?: item.imageUrl
StoreInstalls.begin(folder, Store.EA, item.id, item.title, cover, item.imageUrl)
if (cancelled.get()) return null
val tier = StoreDownloadTier.current(app)
handle.log("ea: \"${item.title}\" tier=${tier.id} window=${tier.networkWindow}")
val request = JSONObject()
.put("url", url)
.put("gameDir", folder.absolutePath)
.put("maxWorkers", tier.networkWindow.coerceIn(1, 64))
.put("processWorkers", tier.processWorkers)
.put("caBundle", "")
val shown = AtomicLong(-1)
val downloading = AtomicBoolean(false)
val listener = object : AmazonNative.InstallListener {
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) {
handle.stage(DownloadStage.MANIFEST, "Checking files…")
handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true)
}
override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) }
override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) {
if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD)
while (true) {
val before = shown.get()
if (done < before) return
if (shown.compareAndSet(before, done)) break
}
handle.progress(done, total, "Downloading ($files/$filesTotal files)")
}
override fun onLog(line: String) { handle.log("ea: $line") }
override fun beforeReplace(): Boolean = try {
StoreInstalls.markUpdating(folder)
true
} catch (e: Exception) {
handle.log("ea: the install record could not be switched to updating (${e.javaClass.simpleName})")
false
}
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
}
val outcome = EaNative.install(request, listener, cancelled)
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The EA install failed" })
val result = outcome.result ?: JSONObject()
result.optLong("totalBytes").takeIf { it > 0 }?.let { EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() }
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
val sidecar = sidecarFor(app, item, folder, game, version, cover) { handle.log("ea: $it") }
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
}
companion object {
fun locale(context: Context, available: List<String>): String {
val wanted = StoreLanguage.localeFor(null, context)
val exact = "${wanted.language}_${wanted.country}"
return available.firstOrNull { it.equals(exact, true) }
?: available.firstOrNull { it.substringBefore('_').equals(wanted.language, true) }
?: available.firstOrNull { it.equals("en_US", true) }
?: "en_US"
}
internal fun sidecarFor(app: Context, item: CatalogItem, folder: File, game: EaGame?, version: String, cover: String?, log: (String) -> Unit): StoreGameSidecar {
val manifest = EaManifest.read(folder)
if (manifest == null) log("no installerdata.xml in ${folder.name}")
val launcher = manifest?.launcher()
val candidates = listOfNotNull(launcher?.path, game?.executePath?.takeIf { it.isNotEmpty() })
var exe = candidates.firstNotNullOfOrNull { EaPaths.relativeOnDisk(folder, EaPaths.relative(it)) }.orEmpty()
if (exe.isEmpty()) exe = StoreExe.pick(folder, null, item.title)
if (exe.isEmpty()) { log("no exe found; pick one in Steam settings › Added games"); exe = "game.exe" } else log("exe=$exe")
val args = EaPaths.splitArgs(listOf(game?.parameters.orEmpty(), launcher?.parameters.orEmpty()).filter { it.isNotBlank() }.joinToString(" "))
val keys = JSONArray()
listOfNotNull(game?.installCheck, game?.executePath, launcher?.path).mapNotNull { EaPaths.registered(it) }
.distinctBy { it.key.lowercase() + "|" + it.value.lowercase() }
.forEach { keys.put(JSONArray().put(it.key).put(it.value)) }
val extra = linkedMapOf(
"contentId" to (game?.contentId ?: item.extra["contentId"].orEmpty()),
"slug" to (game?.slug ?: item.extra["slug"].orEmpty()),
"multiplayerId" to game?.multiplayerId.orEmpty(),
"masterTitleId" to game?.masterTitleId.orEmpty(),
"locale" to locale(app, game?.locales.orEmpty()),
"registry" to keys.toString(),
"touchup" to manifest?.touchupPath.orEmpty().replace('\\', '/').trimStart('/'),
"touchupArgs" to manifest?.touchupParameters.orEmpty(),
"contentIds" to manifest?.contentIds.orEmpty().joinToString(","),
"displayName" to manifest?.title(locale(app, game?.locales.orEmpty())).orEmpty(),
).filterValues { it.isNotEmpty() }
return StoreGameSidecar(
Store.EA, item.id, item.title, exe = exe, args = args,
installVersion = version.ifEmpty { manifest?.version.orEmpty() }, installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl, extra = extra, cloud = false, logo = item.logoUrl,
)
}
}
}
@@ -0,0 +1,138 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.UUID
object EaLaunch {
private const val TAG = "EaLaunch"
const val ENV_FILE = ".droiddeck-ea-env"
private fun installed(context: Context, id: String): Pair<File, StoreGameSidecar>? =
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
StoreGameSidecar.read(f)?.takeIf { it.store == Store.EA && it.id == id && it.isInstalled }?.let { f to it }
}
internal fun environment(
offerId: String,
contentId: String,
locale: String,
generic: String,
access: String,
opaque: String,
account: EaAuth.Account,
session: String = UUID.randomUUID().toString(),
): LinkedHashMap<String, String> = linkedMapOf(
"EAAuthCode" to "unavailable",
"EAEgsProxyIpcPort" to "0",
"EAEntitlementSource" to "EA",
"EAExternalSource" to "EA",
"EAFreeTrialGame" to "false",
"EAGameLocale" to locale,
"EAGenericAuthToken" to generic,
"EALaunchCode" to "unavailable",
"EALaunchOwner" to "EA",
"EALaunchEAID" to account.eaId.ifEmpty { account.displayName },
"EALaunchEnv" to "production",
"EALaunchOfflineMode" to "false",
"EALsxPort" to EaLsx.PORT.toString(),
"EARtPLaunchCode" to EaCrypto.rtpCode().toString(),
"EASecureLaunchTokenTemp" to account.userId,
"EASteamProxyIpcPort" to "0",
"OriginSessionKey" to session,
"ContentId" to contentId,
"EAOnErrorExitRetCode" to "1",
"EAConnectionId" to offerId,
"EALicenseToken" to offerId,
"EALaunchUserAuthToken" to opaque,
"EAAccessTokenJWS" to access,
)
/** Adds what installs made before the manifest's content ids and title were recorded are missing. */
internal fun backfill(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
if (sidecar.extra.containsKey("contentIds") || sidecar.extra.containsKey("displayName")) return sidecar
val manifest = EaManifest.read(folder) ?: return sidecar
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
val added = mapOf(
"contentIds" to manifest.contentIds.joinToString(","),
"displayName" to manifest.title(locale).orEmpty(),
).filterValues { it.isNotEmpty() }
if (added.isEmpty()) return sidecar
val updated = sidecar.copy(extra = sidecar.extra + added)
runCatching { updated.write(folder) }.onFailure { Log.w(TAG, "sidecar: ${it.javaClass.simpleName}") }
return updated
}
/** Answers the guest's request right before an EA game starts: session, licence and the LSX endpoint. */
fun prepare(context: Context, request: JSONObject): JSONObject {
val app = context.applicationContext
val id = request.optString("id")
val reply = JSONObject().put("ok", false)
val (folder, recorded) = installed(app, id) ?: return reply.put("reason", "not-installed")
val sidecar = backfill(folder, recorded)
runCatching { com.droiddeck.launcher.stores.StoreLaunch.refreshLauncher(folder, sidecar) }
File(folder, ENV_FILE).delete()
val account = EaAuth.load(app) ?: return reply.put("reason", "signed-out")
val access = EaAuth.accessToken(app) ?: return reply.put("reason", "sign-in-expired")
val contentId = sidecar.extra["contentId"].orEmpty()
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
val core = EaOoa.coreDir(folder, sidecar.exe)
val hashVersion = core?.let(EaOoa::activationVersion) ?: 1
val hardware = request.optJSONObject("hardware")?.let(EaOoa.Hardware::fromJson)?.takeIf { it.complete(hashVersion) }
val legacy = EaOoa.legacyCore(core)
val lifetime = if (legacy) 14400 else 550
val opaque = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", lifetime).orEmpty()
val known = (sidecar.extra["contentIds"].orEmpty().split(',') + sidecar.extra["multiplayerId"].orEmpty()).map { it.trim() }.filter { it.isNotEmpty() }.toSet()
val lsx = EaLsx.start(app, EaLsx.Game(id, contentId, sidecar.title, sidecar.installVersion, locale, hardware, hashVersion, opaque,
System.currentTimeMillis() + (lifetime - 30) * 1000L, known))
val env = environment(id, contentId, locale, if (legacy && opaque.isNotEmpty()) opaque else access, access, opaque, EaAuth.load(app) ?: account)
val tmp = File(folder, "$ENV_FILE.tmp")
tmp.writeText(env.entries.joinToString("\r\n", postfix = "\r\n") { "${it.key}=${it.value}" })
tmp.renameTo(File(folder, ENV_FILE))
val licenses = JSONArray()
val notes = ArrayList<String>()
if (core != null) {
val ids = (listOf(contentId) + sidecar.extra["contentIds"].orEmpty().split(',')).map { it.trim() }.filter { it.isNotEmpty() }.distinct()
if (hardware == null) notes += "no-hardware" else {
val hash = EaOoa.machineHash(hardware, hashVersion)
for (content in ids) {
notes += try {
for ((path, data) in EaOoa.files(core, EaOoa.request(access, content, hash))) {
licenses.put(JSONObject().put("path", path).put("data", Base64.encodeToString(data, Base64.NO_WRAP)))
}
"$content:ok"
} catch (e: Exception) {
Log.w(TAG, "licence $content for $id: ${e.message}")
com.droiddeck.launcher.stores.StoresState.logLine("ea licence $content for $id v$hashVersion legacy=$legacy: ${e.message}")
"$content:refused"
}
}
}
}
if (core != null && notes.isNotEmpty() && notes.none { it.endsWith(":ok") }) runCatching {
val rows = EaApi.legacyEntitlements(access)
com.droiddeck.launcher.stores.StoresState.logLine("ea legacy entitlements for $id: ${rows.size} rows")
for (r in rows) com.droiddeck.launcher.stores.StoresState.logLine("ea legacy " + listOf("productId", "entitlementTag", "entitlementType", "externalType", "groupName", "status", "statusReasonCode", "originPermissions", "isSubscription", "useCount", "version", "grantDate").joinToString(" ") { k -> "$k=${r.optString(k)}" } + " cdKey=" + (if (r.optString("cdKey").let { it.isNotEmpty() && it != "null" }) "yes" else "no"))
}.onFailure { com.droiddeck.launcher.stores.StoresState.logLine("ea legacy entitlements for $id: ${it.javaClass.simpleName} ${it.message.orEmpty().take(300)}") }
val licenseNote = if (core == null) "none-needed" else "v$hashVersion " + notes.joinToString(",")
Log.i(TAG, "prepared $id lsx=$lsx legacy=$legacy opaque=${opaque.isNotEmpty()} licence=$licenseNote")
return reply.put("ok", true).put("lsx", lsx).put("license", licenseNote).put("licenses", licenses)
}
}
object EaSizes {
fun size(context: Context, item: com.droiddeck.launcher.stores.CatalogItem): Long {
val token = EaAuth.accessToken(context) ?: return 0L
val url = runCatching { EaApi.downloadUrl(token, item.id) }.getOrNull() ?: return 0L
return EaNative.call("zip.size", JSONObject().put("url", url))?.optLong("totalBytes") ?: 0L
}
}
@@ -0,0 +1,293 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.content.SharedPreferences
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.mergeItems
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
object EaPrefs {
const val FILE = "stores.ea"
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
}
data class EaGame(
val offerId: String,
val contentId: String,
val title: String,
val slug: String,
val tall: String = "",
val wide: String = "",
val logo: String = "",
val version: String = "",
val executePath: String = "",
val installCheck: String = "",
val parameters: String = "",
val multiplayerId: String = "",
val masterTitleId: String = "",
val cloudSaves: String = "",
val locales: List<String> = emptyList(),
) {
fun toJson(): JSONObject = JSONObject()
.put("offerId", offerId).put("contentId", contentId).put("title", title).put("slug", slug)
.put("tall", tall).put("wide", wide).put("logo", logo).put("version", version)
.put("executePath", executePath).put("installCheck", installCheck).put("parameters", parameters)
.put("multiplayerId", multiplayerId).put("masterTitleId", masterTitleId).put("cloudSaves", cloudSaves)
.put("locales", JSONArray(locales))
companion object {
fun fromJson(o: JSONObject): EaGame? {
val locales = o.optJSONArray("locales")
return EaGame(
offerId = o.optString("offerId").ifEmpty { return null },
contentId = o.optString("contentId"),
title = o.optString("title"),
slug = o.optString("slug"),
tall = o.optString("tall"),
wide = o.optString("wide"),
logo = o.optString("logo"),
version = o.optString("version"),
executePath = o.optString("executePath"),
installCheck = o.optString("installCheck"),
parameters = o.optString("parameters"),
multiplayerId = o.optString("multiplayerId"),
masterTitleId = o.optString("masterTitleId"),
cloudSaves = o.optString("cloudSaves"),
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
)
}
}
}
object EaLibrary {
private const val KEY_CACHE = "library_cache"
private const val KEY_SYNCED_AT = "library_synced_at"
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
private const val ART_TTL_MS = 7 * 24 * 60 * 60 * 1000L
private const val ART_THREADS = 4
const val STORE_URL = "https://www.ea.com/ea-app/library"
private val gate = SyncGate()
private val generation = java.util.concurrent.atomic.AtomicInteger()
/** Drops the cache and makes any sync already running drop its results, in one step. */
fun reset(context: Context) {
synchronized(cacheLock) {
generation.incrementAndGet()
EaPrefs.get(context).edit().remove(KEY_CACHE).remove(KEY_SYNCED_AT).commit()
}
}
internal fun snapshot(app: Context): Pair<Int, List<EaGame>> = synchronized(cacheLock) { generation.get() to cached(app) }
internal fun storeCache(app: Context, games: List<EaGame>, gen: Int): Boolean = synchronized(cacheLock) {
gen == generation.get() &&
EaPrefs.get(app).edit().putString(KEY_CACHE, JSONArray(games.map { it.toJson() }).toString()).putLong(KEY_SYNCED_AT, System.currentTimeMillis()).commit()
}
private val cacheLock = Any()
private val store = Store.EA
fun cached(context: Context): List<EaGame> {
val text = EaPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
val arr = runCatching { JSONArray(text) }.getOrNull() ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.let(EaGame::fromJson) }
}
fun game(context: Context, offerId: String): EaGame? = cached(context).firstOrNull { it.offerId == offerId }
fun toCatalogItem(context: Context, g: EaGame): CatalogItem {
val prefs = EaPrefs.get(context)
val tall = g.tall.ifEmpty { prefs.getString("poster_${g.offerId}", null).orEmpty() }
return CatalogItem(
store = store,
id = g.offerId,
title = g.title.ifBlank { g.slug },
imageUrl = g.wide.ifEmpty { tall }.ifEmpty { null },
tallImageUrl = tall.ifEmpty { g.wide }.ifEmpty { null },
publisher = "Electronic Arts",
owned = true,
sizeBytes = prefs.getLong("size_${g.offerId}", 0L),
storeUrl = STORE_URL,
extra = mapOf("contentId" to g.contentId, "slug" to g.slug, "version" to g.version),
logoUrl = g.logo.ifEmpty { null },
)
}
/** One entry per game: owned editions of the same base game collapse onto the base offer. */
internal fun pick(owned: List<EaApi.OwnedProduct>, offers: Map<String, EaApi.Offer>): List<Pair<EaApi.OwnedProduct, EaApi.Offer>> {
val usable = owned.filter { o ->
val offer = offers[o.offerId] ?: return@filter false
!o.trial && o.downloadable && offer.downloadable &&
(offer.displayType.isEmpty() || offer.displayType.equals("FullGame", true)) &&
(o.gameType.isEmpty() || o.gameType.equals("BASE_GAME", true))
}
return usable.groupBy { it.baseSlug.ifEmpty { it.slug }.ifEmpty { it.offerId } }.values.map { group ->
val chosen = group.firstOrNull { it.baseSlug.isEmpty() } ?: group.first()
chosen to offers.getValue(chosen.offerId)
}.sortedBy { it.first.name.lowercase() }
}
private fun art(app: Context, token: String, slugs: Collection<String>, gen: Int): Map<String, EaApi.Images> {
val prefs = EaPrefs.get(app)
val now = System.currentTimeMillis()
val out = java.util.concurrent.ConcurrentHashMap<String, EaApi.Images>()
val pool = Executors.newFixedThreadPool(ART_THREADS)
try {
for (slug in slugs.filter { it.isNotEmpty() }) {
if (gen != generation.get()) break
val cached = prefs.getString("art_$slug", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
if (cached != null && now - cached.optLong("at") < ART_TTL_MS) {
out[slug] = EaApi.Images(cached.optString("tall").ifEmpty { null }, cached.optString("wide").ifEmpty { null }, cached.optString("logo").ifEmpty { null })
continue
}
pool.execute {
if (gen != generation.get()) return@execute
val images = runCatching { EaApi.images(token, slug) }.getOrNull() ?: return@execute
out[slug] = images
prefs.edit().putString(
"art_$slug",
JSONObject().put("at", now).put("tall", images.tall.orEmpty()).put("wide", images.wide.orEmpty()).put("logo", images.logo.orEmpty()).toString(),
).apply()
}
}
} finally {
pool.shutdown()
pool.awaitTermination(2, TimeUnit.MINUTES)
}
return out
}
private fun posters(app: Context, games: List<EaGame>, gen: Int) {
val prefs = EaPrefs.get(app)
val todo = games.filter { it.tall.isEmpty() && !prefs.contains("poster_${it.offerId}") }.take(40)
if (todo.isEmpty()) return
val pool = Executors.newFixedThreadPool(ART_THREADS)
try {
for (g in todo) pool.execute {
if (gen != generation.get()) return@execute
prefs.edit().putString("poster_${g.offerId}", runCatching { StoreNet.sgdbPoster(g.title) }.getOrDefault("")).apply()
}
} finally {
pool.shutdown()
pool.awaitTermination(5, TimeUnit.MINUTES)
}
}
private fun publish(app: Context, games: List<EaGame>, gen: Int = generation.get()) {
if (gen != generation.get()) return
val items = games.map { toCatalogItem(app, it) }
StoresState.post {
if (gen != generation.get()) return@post
StoresState.library[store] = mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
}
}
private inline fun <T> withToken(app: Context, token: String, call: (String) -> T): T = try {
call(token)
} catch (e: EaApiException) {
if (e.status != 401) throw e
call(EaAuth.refreshNow(app, token) ?: throw e)
}
fun sync(context: Context, force: Boolean) {
if (!gate.enter()) return
var forced = force
do {
runCatching { syncOnce(context.applicationContext, forced) }
forced = true
} while (gate.leave())
}
private fun syncOnce(app: Context, force: Boolean) {
val (gen, cache) = snapshot(app)
val prefs = EaPrefs.get(app)
val quiet = cache.isNotEmpty() && !force
fun status(id: Int) { if (!quiet) StoresState.post { StoresState.status[store] = app.getString(id) } }
fun problem(text: String?) {
StoresState.post {
StoresState.status.remove(store)
if (text == null) StoresState.problems.remove(store) else StoresState.problems[store] = text
}
}
try {
if (cache.isNotEmpty()) publish(app, cache, gen)
if (!force && cache.isNotEmpty() && System.currentTimeMillis() - prefs.getLong(KEY_SYNCED_AT, 0L) in 0 until THROTTLE_MS) {
return problem(null)
}
status(R.string.stores_sync_sign_in)
val token = EaAuth.accessToken(app) ?: return problem(StoresState.notSignedInLine(store))
status(R.string.stores_sync_game_list)
val owned = withToken(app, token) { EaApi.owned(it) }
val offers = withToken(app, token) { EaApi.offers(it, owned.map { o -> o.offerId }) }.associateBy { it.offerId }
val picked = pick(owned, offers)
StoresState.logLine("ea: ${owned.size} owned products, ${picked.size} games")
for ((o, offer) in picked) StoresState.logLine(
"ea: owned ${o.offerId} content=${offer.contentId} master=${offer.masterTitleId} mp=${offer.multiplayerId} via=${o.ownership.joinToString("+")} type=${o.gameType}")
if (picked.isEmpty()) return problem(if (cache.isEmpty()) app.getString(R.string.stores_sync_no_games, store.label) else null)
if (gen != generation.get()) return
status(R.string.stores_sync_cover_art)
val images = art(app, token, picked.map { it.first.slug }, gen)
val games = picked.map { (o, offer) ->
val img = images[o.slug]
EaGame(
offerId = o.offerId, contentId = offer.contentId, title = o.name.ifBlank { offer.displayName }, slug = o.slug,
tall = img?.tall.orEmpty(), wide = img?.wide.orEmpty(), logo = img?.logo.orEmpty(), version = offer.gameVersion,
executePath = offer.executePathOverride, installCheck = offer.installCheckOverride, parameters = offer.executeParameters,
multiplayerId = offer.multiplayerId, masterTitleId = offer.masterTitleId, cloudSaves = offer.cloudSaves, locales = offer.locales,
)
}
if (!storeCache(app, games, gen)) return
posters(app, games, gen)
if (gen != generation.get()) return
publish(app, games, gen)
problem(null)
} catch (e: EaApiException) {
StoresState.logLine("ea: library sync failed: ${e.message}")
if (e.status == 401) StoresState.markSignInExpired(store)
problem(if (cache.isEmpty()) e.message else null)
} catch (e: Exception) {
StoresState.logLine("ea: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty()}")
problem(if (cache.isEmpty()) e.message ?: app.getString(R.string.stores_sync_failed) else null)
}
}
}
/** One sync at a time; a request made meanwhile makes the running one go round again rather than being lost. */
internal class SyncGate {
private var busy = false
private var again = false
/** True when the caller should run; false when a running sync will pick the request up. */
@Synchronized
fun enter(): Boolean {
if (busy) {
again = true
return false
}
busy = true
return true
}
/** True when another round was requested while running; the caller stays the runner. */
@Synchronized
fun leave(): Boolean {
if (again) {
again = false
return true
}
busy = false
return false
}
}
@@ -0,0 +1,435 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.util.Log
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.io.StringReader
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* The local SDK endpoint EA games talk to in place of the EA app (LSX: XML over TCP, AES after a
* challenge). It answers for the one game DroidDeck last prepared.
*/
object EaLsx {
private const val TAG = "EaLsx"
const val PORT = 3216
private const val SENDER = "EALS"
private const val CHALLENGE_KEY = "cacf897a20b6d612ad0c05e011df52bb"
private const val CHALLENGE_VERSION = "10,5,30,15625"
private const val LANGUAGES = "ar_SA,de_DE,en_US,es_ES,es_MX,fr_FR,it_IT,ja_JP,ko_KR,pl_PL,pt_BR,ru_RU,zh_CN,zh_TW"
private val SERVICES = listOf(
"EbisuSDK" to "SDK", "EbisuSDK" to "PROFILE", "XMPP" to "PRESENCE", "XMPP" to "FRIENDS", "Commerce" to "COMMERCE",
"EbisuSDK" to "RECENTPLAYER", "EbisuSDK" to "IGO", "EbisuSDK" to "MISC", "EALS" to "LOGIN", "EbisuSDK" to "UTILITY",
"XMPP" to "XMPP", "XMPP" to "CHAT", "EbisuSDK" to "IGO_EVENT", "EALS" to "EALS_EVENTS", "EbisuSDK" to "LOGIN_EVENT",
"XMPP" to "INVITE_EVENT", "EbisuSDK" to "PROFILE_EVENT", "XMPP" to "PRESENCE_EVENT", "XMPP" to "FRIENDS_EVENT",
"Commerce" to "COMMERCE_EVENT", "XMPP" to "CHAT_EVENT", "EbisuSDK" to "DOWNLOAD_EVENT", "EbisuSDK" to "PERMISSION",
"EbisuSDK" to "RESOURCES", "EbisuSDK" to "BLOCKED_USERS", "EbisuSDK" to "BLOCKED_USER_EVENT", "EbisuSDK" to "GET_USERID",
"EbisuSDK" to "ONLINE_STATUS_EVENT", "EbisuSDK" to "ACHIEVEMENT", "EbisuSDK" to "ACHIEVEMENT_EVENT",
"EbisuSDK" to "BROADCAST_EVENT", "PI" to "PROGRESSIVE_INSTALLATION", "PI" to "PROGRESSIVE_INSTALLATION_EVENT",
"EbisuSDK" to "CONTENT",
)
class Game(
val offerId: String,
val contentId: String,
val title: String,
val version: String,
val locale: String,
val hardware: EaOoa.Hardware?,
val hashVersion: Int,
val token: String = "",
val tokenExpiresAt: Long = 0L,
val contentIds: Set<String> = emptySet(),
) {
val validToken: String? get() = token.takeIf { it.isNotEmpty() && System.currentTimeMillis() < tokenExpiresAt }
}
/** The parts of a request a reply depends on. */
class Message(val recipient: String, val id: String, val name: String, val attrs: Map<String, String>, val children: List<Pair<String, String>>)
interface Account {
val userId: String
val personaId: String
val persona: String
fun authCode(clientId: String, scope: String?): String?
fun license(contentId: String, requestTicket: String, ticketEngine: String): String?
fun entitlements(group: String): List<Map<String, String>>
fun token(): String?
}
@Volatile var game: Game? = null
@Volatile private var account: Account? = null
/** The one session of the current launch that may act for the account. */
/** Bumped whenever the launch changes; a connection accepted under an older one is not admitted. */
@Volatile internal var generation = 0
private val holder = java.util.concurrent.atomic.AtomicReference<Any?>(null)
private var server: ServerSocket? = null
private val lock = Any()
private const val MAX_CLIENTS = 8
private val WHITESPACE = Regex("[ \t]+")
fun start(context: Context, game: Game): Boolean {
synchronized(lock) {
closeSessions()
this.game = game
account = AppAccount(context.applicationContext)
runCatching { server?.close() }
return try {
val socket = ServerSocket().apply {
reuseAddress = true
bind(java.net.InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 8)
}
server = socket
Thread({ accept(socket) }, "ea-lsx").apply { isDaemon = true }.start()
Log.i(TAG, "listening on $PORT")
true
} catch (e: Exception) {
Log.w(TAG, "could not listen on $PORT: ${e.javaClass.simpleName}")
false
}
}
}
/** Ends the endpoint; with [offerId], only if it still serves that game, so a late exit cannot stop a newer launch. */
fun stop(offerId: String? = null) {
synchronized(lock) {
if (offerId != null && game?.offerId != offerId) return
runCatching { server?.close() }
server = null
game = null
account = null
closeSessions()
}
}
private fun closeSessions() {
generation++
holder.set(null)
synchronized(sessions) { sessions.forEach { it.trusted = false } }
for (socket in open) runCatching { socket.close() }
open.clear()
}
private val open = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<Socket, Boolean>())
private const val HANDSHAKE_TIMEOUT_MS = 15_000
private const val UNTRUSTED_IDLE_MS = 10 * 60 * 1000
private fun accept(socket: ServerSocket) {
while (!socket.isClosed) {
val client = runCatching { socket.accept() }.getOrNull() ?: continue
val verified = ownerUid(client)?.let { it == android.os.Process.myUid() }
if (verified == false) {
Log.w(TAG, "refused a connection from another app")
runCatching { client.close() }
continue
}
val session = runCatching { Session(client, client.getOutputStream(), verified == true) }.getOrNull()
val live = session != null && synchronized(lock) { server === socket && admit(session).also { if (it) open += client } }
if (!live) {
runCatching { client.close() }
continue
}
Thread({ try { serve(client, session!!) } finally { synchronized(sessions) { sessions -= session!! }; open -= client } }, "ea-lsx-client").apply { isDaemon = true }.start()
}
}
/**
* The uid owning the connecting socket, or null where SELinux hides /proc/net. The guest runs
* under this app's uid, so any other uid is another app; an unknown owner never gets credentials.
*/
private fun ownerUid(client: Socket): Int? {
val port = client.port
return listOf("/proc/net/tcp6", "/proc/net/tcp").firstNotNullOfOrNull { path ->
runCatching { java.io.File(path).readLines() }.getOrNull()?.let { owner(it, port) }
}
}
internal fun owner(lines: List<String>, port: Int): Int? = lines.drop(1).firstNotNullOfOrNull { line ->
val f = line.trim().split(WHITESPACE)
val local = f.getOrNull(1)?.substringAfterLast(':')?.toIntOrNull(16)
val remote = f.getOrNull(2)?.substringAfterLast(':')?.toIntOrNull(16)
if (local == port && remote == PORT) f.getOrNull(7)?.toIntOrNull() else null
}
/**
* Adds [session] unless the cap is reached by sessions that cannot be replaced; otherwise the
* oldest session that never claimed the prepared game (then the oldest untrusted one) makes room.
* Counting and eviction share one lock, so the live total never passes the cap.
*/
internal fun admit(session: Session): Boolean = synchronized(sessions) {
if (sessions.count { !it.evicted } >= MAX_CLIENTS) {
val candidates = sessions.filter { !it.trusted && !it.evicted }
val victim = candidates.firstOrNull { !it.claimed } ?: candidates.firstOrNull() ?: return false
victim.evicted = true
runCatching { victim.socket.close() }
}
sessions += session
true
}
internal val sessions = LinkedHashSet<Session>()
/** Trust and eviction exclude each other: an evicted session never becomes trusted, a trusted one is never evicted. */
internal fun promote(session: Session, matches: Boolean) {
synchronized(sessions) { session.trusted = matches && !session.evicted && session.born == generation && holder.compareAndSet(null, session) }
}
internal class Session(val socket: Socket, val output: OutputStream, val verified: Boolean) {
val born = generation
val connectedAt = System.currentTimeMillis()
@Volatile var evicted = false
@Volatile var claimed = false
var key: ByteArray? = null
@Volatile var trusted = false
var pending: ByteArray? = null
@Synchronized
fun send(xml: String) {
val key = this.key
val text = if (key == null) xml else EaCrypto.hex(EaCrypto.ecbEncrypt(xml.toByteArray(), key))
output.write(text.toByteArray())
output.write(0)
output.flush()
}
}
private fun note(line: String) = com.droiddeck.launcher.stores.StoresState.logLine("ea lsx: $line")
private fun serve(client: Socket, session: Session) {
client.tcpNoDelay = true
client.soTimeout = HANDSHAKE_TIMEOUT_MS
val seen = LinkedHashSet<String>()
var why = "closed by the game"
note("game connected")
try {
session.send("<LSX><Event sender=\"$SENDER\"><Challenge build=\"release\" key=\"$CHALLENGE_KEY\" version=\"$CHALLENGE_VERSION\"/></Event></LSX>")
val input = client.getInputStream()
val pending = ByteArrayOutputStream()
val chunk = ByteArray(16 * 1024)
while (true) {
if (session.key == null) {
val left = session.connectedAt + HANDSHAKE_TIMEOUT_MS - System.currentTimeMillis()
if (left <= 0) throw java.net.SocketTimeoutException()
client.soTimeout = left.toInt()
}
val n = input.read(chunk)
if (n < 0) break
pending.write(chunk, 0, n)
for (frame in frames(pending, session.key)) {
for (one in Regex("<LSX>.*?</LSX>", RegexOption.DOT_MATCHES_ALL).findAll(frame)) {
val message = parse(one.value) ?: continue
if (seen.add(message.name)) note("request ${message.name}")
val reply = runCatching { answer(session, message) }
.onFailure { note("${message.name} failed: ${it.javaClass.simpleName}") }.getOrNull()
if (reply != null) session.send("<LSX><Response sender=\"${esc(message.recipient)}\" id=\"${esc(message.id)}\">$reply</Response></LSX>")
session.pending?.let { session.key = it; session.pending = null }
if (session.key != null) {
val idle = if (session.trusted) 0 else UNTRUSTED_IDLE_MS
if (client.soTimeout != idle) client.soTimeout = idle
}
}
}
if (pending.size() > 4 * 1024 * 1024) { why = "message too large"; break }
}
} catch (e: java.net.SocketTimeoutException) {
why = "no trusted handshake in time"
} catch (e: Exception) {
why = e.javaClass.simpleName
} finally {
holder.compareAndSet(session, null)
note("game disconnected ($why)")
runCatching { client.close() }
}
}
/**
* Complete messages in what has arrived so far. Messages normally end in a NUL; one that does
* not counts once it holds a whole `<LSX>` element, plain or decrypted.
*/
private fun frames(pending: ByteArrayOutputStream, key: ByteArray?): List<String> {
val bytes = pending.toByteArray()
val out = ArrayList<String>()
var start = 0
for (i in bytes.indices) {
if (bytes[i].toInt() != 0) continue
if (i > start) decode(String(bytes, start, i - start, Charsets.UTF_8), key)?.let(out::add)
start = i + 1
}
var rest = String(bytes, start, bytes.size - start, Charsets.UTF_8)
if (rest.isNotBlank()) decode(rest, key)?.takeIf { it.contains("</LSX>") }?.let { out += it; rest = "" }
pending.reset()
pending.write(rest.toByteArray(Charsets.UTF_8))
return out
}
private fun decode(frame: String, key: ByteArray?): String? {
val text = frame.trim()
if (text.isEmpty()) return null
if (text.startsWith("<")) return text
val k = key ?: return null
return runCatching { String(EaCrypto.ecbDecrypt(EaCrypto.unhex(text), k)) }.getOrNull()
}
internal fun parse(xml: String): Message? = runCatching {
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(StringReader(xml))
var recipient = ""
var id = ""
var name = ""
val attrs = HashMap<String, String>()
val children = ArrayList<Pair<String, String>>()
var depth = 0
var child = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
depth++
when (depth) {
2 -> if (parser.name == "Request") {
recipient = parser.getAttributeValue(null, "recipient").orEmpty()
id = parser.getAttributeValue(null, "id").orEmpty()
} else return@runCatching null
3 -> {
name = parser.name
for (i in 0 until parser.attributeCount) attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i)
}
4 -> child = parser.name
}
}
XmlPullParser.TEXT -> if (depth == 4) children += child to parser.text.trim()
XmlPullParser.END_TAG -> depth--
}
parser.next()
}
if (name.isEmpty()) null else Message(recipient, id, name, attrs, children)
}.getOrNull()
private fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
private fun tag(name: String, vararg attrs: Pair<String, Any?>, body: String? = null): String {
val a = attrs.filter { it.second != null }.joinToString("") { " ${it.first}=\"${esc(it.second.toString())}\"" }
return if (body == null) "<$name$a/>" else "<$name$a>$body</$name>"
}
private fun now() = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
private fun answer(session: Session, m: Message): String? {
val g = game
val who = account
val person = who?.takeIf { session.trusted }
val locale = g?.locale ?: "en_US"
return when (m.name) {
"ChallengeResponse" -> {
val answer = EaCrypto.challengeAnswer(m.attrs["key"].orEmpty())
val version = m.attrs["version"].orEmpty()
val seed = EaCrypto.challengeSeed(version, answer) ?: 0
val content = m.children.firstOrNull { it.first == "ContentId" }?.second.orEmpty()
session.claimed = g != null && content.isNotEmpty() && (content == g.contentId || content in g.contentIds)
val matches = session.verified && session.claimed
promote(session, matches)
note("challenge version '$version' content '$content' ${when { session.trusted -> "matches the prepared game"; matches -> "matches, but another session holds this launch"; !session.verified -> "comes from an unverified peer"; else -> "does not match the prepared game" }}")
session.pending = EaCrypto.lsxKey(seed)
Log.i(TAG, "game connected: ${m.children.firstOrNull { it.first == "Title" }?.second.orEmpty()}")
tag("ChallengeAccepted", "response" to answer)
}
"GetConfig" -> tag("GetConfigResponse", body = SERVICES.joinToString("") { tag("Service", "Name" to it.first, "Facility" to it.second) })
"GetProfile" -> tag(
"GetProfileResponse",
"Persona" to person?.persona.orEmpty(), "SubscriberLevel" to 0, "CommerceCurrency" to "USD", "IsTrialSubscriber" to false,
"Country" to "US", "UserId" to person?.userId.orEmpty(), "GeoCountry" to "US", "AvatarId" to "", "IsSubscriber" to false,
"IsSteamSubscriber" to false, "PersonaId" to person?.personaId.orEmpty(), "IsUnderAge" to false, "UserIndex" to 0,
)
"GetSetting" -> tag("GetSettingResponse", "Setting" to when (m.attrs["SettingId"]) {
"ENVIRONMENT" -> "production"
"LANGUAGE" -> locale
else -> "false"
})
"GetGameInfo" -> tag("GetGameInfoResponse", "GameInfo" to when (m.attrs["GameInfoId"]) {
"FREETRIAL" -> "false"
"LANGUAGES" -> LANGUAGES
"INSTALLED_LANGUAGE" -> locale
"INSTALLED_VERSION", "AVAILABLE_VERSION" -> g?.version.orEmpty()
else -> ""
})
"GetAllGameInfo" -> tag(
"GetAllGameInfoResponse",
"FullGamePurchased" to true, "FullGameReleased" to true, "InstalledVersion" to (g?.version ?: "0"), "MaxGroupSize" to 16,
"Languages" to LANGUAGES, "Expiration" to "0000-00-00T00:00:00", "UpToDate" to true, "HasExpiration" to false,
"EntitlementSource" to "EA", "AvailableVersion" to (g?.version ?: "0"), "DisplayName" to g?.title.orEmpty(),
"FreeTrial" to false, "InstalledLanguage" to locale, "FullGameReleaseDate" to "2000-01-01T00:00:00", "SystemTime" to now(),
)
"GetInternetConnectedState" -> tag("InternetConnectedState", "connected" to 1)
"IsProgressiveInstallationAvailable" -> tag("IsProgressiveInstallationAvailableResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Available" to false)
"AreChunksInstalled" -> tag(
"AreChunksInstalledResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Installed" to true,
body = m.children.joinToString("") { tag(it.first, body = esc(it.second)) },
)
"GetAuthCode" -> tag("AuthCode", "value" to (who?.takeIf { session.trusted }?.authCode(m.attrs["ClientId"].orEmpty(), m.attrs["Scope"])?.takeIf { session.trusted } ?: "invalid"))
"GetAuthToken" -> tag("AuthToken", "value" to if (!session.trusted) "" else (g?.validToken ?: who?.token().orEmpty()))
"RequestLicense" -> {
val token = who?.takeIf { session.trusted }?.license(g?.contentId.orEmpty(), m.attrs["RequestTicket"].orEmpty(), m.attrs["TicketEngine"].orEmpty())?.takeIf { session.trusted }.orEmpty()
tag("RequestLicenseResponse", "License" to token)
}
"QueryEntitlements" -> {
val group = m.attrs["Group"].orEmpty()
val rows = who?.takeIf { session.trusted }?.entitlements(group).orEmpty()
tag("QueryEntitlementsResponse", body = rows.joinToString("") { e ->
tag(
"Entitlement",
"LastModifiedDate" to "0000-00-00T00:00:00", "EntitlementId" to e["id"], "UseCount" to (e["useCount"] ?: "0"),
"Version" to (e["version"] ?: "0"), "ItemId" to e["productId"], "ResourceId" to "", "GrantDate" to e["grantDate"],
"Group" to group, "EntitlementTag" to e["entitlementTag"], "Type" to e["entitlementType"],
"Expiration" to (e["terminationDate"]?.takeIf { it.isNotEmpty() && it != "null" } ?: "0000-00-00T00:00:00"), "Source" to "",
)
})
}
"GetPresence" -> tag("GetPresenceResponse", "UserId" to person?.userId.orEmpty(), "Presence" to "INGAME")
"QueryPresence" -> tag("QueryPresenceResponse", body = "")
"QueryFriends" -> tag("QueryFriendsResponse", body = "")
"GetBlockList" -> tag("GetBlockListResponse", "Return" to "Success", body = "")
"QueryOffers" -> tag("QueryOffersResponse", body = "")
"QueryImage" -> tag("QueryImageResponse", "Result" to 0, body = "")
"GetVoipStatus" -> tag("GetVoipStatusResponse", "Available" to false, "Active" to false)
"ShowIGOWindow", "SetDownloaderUtilization" -> null
else -> tag("ErrorSuccess", "Code" to 0, "Description" to "")
}
}
private class AppAccount(private val app: Context) : Account {
private val saved get() = EaAuth.load(app)
override val userId: String get() = saved?.userId.orEmpty()
override val personaId: String get() = saved?.personaId.orEmpty()
override val persona: String get() = saved?.eaId?.ifEmpty { null } ?: saved?.displayName.orEmpty()
override fun authCode(clientId: String, scope: String?): String? =
EaAuth.mint(app, clientId, "code", scopes = scope?.split(' ')?.filter { it.isNotBlank() }.orEmpty())
override fun license(contentId: String, requestTicket: String, ticketEngine: String): String? {
val g = game ?: return null
val hw = g.hardware ?: return null
val token = EaAuth.accessToken(app) ?: return null
return runCatching {
EaOoa.request(token, contentId, EaOoa.machineHash(hw, g.hashVersion), requestTicket.ifEmpty { null }, ticketEngine.ifEmpty { null }).gameToken
}.onFailure { Log.w(TAG, "licence: ${it.message}") }.getOrNull()
}
override fun token(): String? = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", 14400)
override fun entitlements(group: String): List<Map<String, String>> {
val token = EaAuth.accessToken(app) ?: return emptyList()
val arr = runCatching { EaApi.entitlements(token, group) }.getOrNull() ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { o ->
o.keys().asSequence().associateWith { k -> o.optString(k) }
}
}
}
}
@@ -0,0 +1,134 @@
package com.droiddeck.launcher.stores.ea
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.File
import java.io.StringReader
/** What `__Installer/installerdata.xml` in an EA build says about running and finishing the install. */
class EaManifest(
val version: String,
val launchers: List<Launcher>,
val touchupPath: String,
val touchupParameters: String,
val contentIds: List<String> = emptyList(),
val titles: Map<String, String> = emptyMap(),
) {
fun title(locale: String): String? = titles[locale] ?: titles["en_US"] ?: titles.values.firstOrNull()
class Launcher(val path: String, val parameters: String, val trial: Boolean)
fun launcher(): Launcher? = launchers.firstOrNull { !it.trial } ?: launchers.firstOrNull()
companion object {
const val RELATIVE = "__Installer/installerdata.xml"
fun decode(bytes: ByteArray): String {
val utf16 = bytes.size >= 2 && ((bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) || (bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()))
val text = if (utf16) String(bytes, Charsets.UTF_16) else String(bytes, Charsets.UTF_8)
return text.trimStart('')
}
fun read(folder: File): EaManifest? {
val file = File(folder, RELATIVE).takeIf { it.isFile } ?: EaPaths.find(folder, RELATIVE) ?: return null
return runCatching { parse(decode(file.readBytes())) }.getOrNull()
}
fun parse(xml: String): EaManifest {
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "")))
val stack = ArrayList<String>()
var version = ""
val launchers = ArrayList<Launcher>()
var touchupPath = ""
var touchupParams = ""
var path = ""
var params = ""
var trial = false
val contentIds = ArrayList<String>()
val titles = LinkedHashMap<String, String>()
var locale = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
val name = parser.name
stack += name
if (name.equals("gameVersion", true)) {
version = parser.getAttributeValue(null, "version") ?: version
}
if (name.equals("launcher", true)) { path = ""; params = ""; trial = false }
if (name.equals("localeInfo", true)) locale = parser.getAttributeValue(null, "locale").orEmpty()
}
XmlPullParser.TEXT -> {
val text = parser.text.trim()
val tag = stack.lastOrNull().orEmpty()
val parent = stack.getOrNull(stack.size - 2).orEmpty()
if (text.isNotEmpty()) when {
parent.equals("launcher", true) && tag.equals("filePath", true) -> path = text
parent.equals("launcher", true) && tag.equals("parameters", true) -> params = text
parent.equals("launcher", true) && tag.equals("trial", true) -> trial = text.equals("true", true)
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("filePath", true) -> touchupPath = text
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("parameters", true) -> touchupParams = text
parent.equals("contentIDs", true) && tag.equals("contentID", true) -> contentIds += text
parent.equals("localeInfo", true) && tag.equals("title", true) && locale.isNotEmpty() -> titles[locale] = text
}
}
XmlPullParser.END_TAG -> {
if (parser.name.equals("launcher", true) && path.isNotEmpty()) launchers += Launcher(path, params, trial)
stack.removeLastOrNull()
}
}
parser.next()
}
return EaManifest(version, launchers, touchupPath, touchupParams, contentIds, titles)
}
}
}
object EaPaths {
private val KEY = Regex("^\\[([^\\]]+)\\](.*)$")
/** `[HKEY_LOCAL_MACHINE\SOFTWARE\Publisher\Game\Install Dir]bin\game.exe` splits into the registry key, value and relative path. */
class Registered(val key: String, val value: String, val rest: String)
fun registered(path: String): Registered? {
val m = KEY.find(path.trim()) ?: return null
val full = m.groupValues[1]
val cut = full.lastIndexOf('\\').takeIf { it > 0 } ?: return null
return Registered(full.substring(0, cut), full.substring(cut + 1), m.groupValues[2])
}
fun relative(path: String): String {
val rest = registered(path)?.rest ?: path.replace("{installLocation}", "", ignoreCase = true)
return rest.replace('\\', '/').trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }.joinToString("/")
}
/** The real file for a path the manifest may spell in a different case. */
fun find(folder: File, relative: String): File? {
var cur = folder
for (part in relative.replace('\\', '/').split('/').filter { it.isNotEmpty() }) {
val names = cur.listFiles() ?: return null
cur = names.firstOrNull { it.name == part } ?: names.firstOrNull { it.name.equals(part, true) } ?: return null
}
return cur.takeIf { it.exists() && it != folder }
}
fun relativeOnDisk(folder: File, relative: String): String? =
find(folder, relative)?.relativeTo(folder)?.path?.replace(File.separatorChar, '/')
fun splitArgs(text: String): List<String> {
val out = ArrayList<String>()
val cur = StringBuilder()
var quoted = false
var any = false
for (c in text) {
when {
c == '"' -> { quoted = !quoted; any = true }
c.isWhitespace() && !quoted -> { if (any || cur.isNotEmpty()) out += cur.toString(); cur.clear(); any = false }
else -> cur.append(c)
}
}
if (any || cur.isNotEmpty()) out += cur.toString()
return out
}
}
@@ -0,0 +1,57 @@
package com.droiddeck.launcher.stores.ea
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
import com.droiddeck.launcher.stores.amazon.AmazonNative
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
object EaNative {
private const val TAG = "EaNative"
const val ENGINE_MISSING = "EA: the store engine is not available"
fun ready(): Boolean = StoresNative.ensureLoaded()
fun call(op: String, request: JSONObject): JSONObject? {
if (!ready()) return null
val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) {
Log.w(TAG, "$op: ${t.javaClass.simpleName}")
null
} ?: return null
return runCatching { JSONObject(text) }.getOrNull()
}
fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome {
if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null)
val done = CountDownLatch(1)
val holder = arrayOfNulls<AmazonNative.InstallOutcome>(1)
val bridge = object : AmazonNative.InstallListener by events {
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) {
holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() })
done.countDown()
}
}
val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) {
return AmazonNative.InstallOutcome(false, false, "The EA install could not start: ${t.javaClass.simpleName}", null)
}
if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install could not start", null)
try {
var forwarded = false
while (true) {
val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false }
if (finished) break
if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true }
}
} finally {
nativeRelease(handle)
}
return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install ended without a result", null)
}
@JvmStatic private external fun nativeCall(op: String, request: String): String?
@JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long
@JvmStatic private external fun nativeCancel(handle: Long)
@JvmStatic private external fun nativeRelease(handle: Long)
}
@@ -0,0 +1,196 @@
package com.droiddeck.launcher.stores.ea
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.File
import java.io.StringReader
import java.net.URLEncoder
import java.security.SecureRandom
/** Origin Online Activation: the per-machine licence an EA game's activation layer checks before it runs. */
object EaOoa {
private const val LICENSES = "https://proxy.novafusion.ea.com/licenses"
const val LICENSE_DIR = "ProgramData/Electronic Arts/EA Services/License"
private const val SIGNATURE_SLOT = 65
/**
* What the game sees of the machine inside its Wine prefix: firmware and OS values from Wine's
* WMI, CPU values from CPUID executed as x86 code, since an emulated game sees the emulator's
* CPUID rather than the host CPU WMI describes.
*/
class Hardware(
val boardManufacturer: String,
val boardSerial: String,
val biosManufacturer: String,
val biosSerial: String,
val osInstallDate: String,
val osSerial: String,
val volumeSerial: String,
val gpuPnpId: String,
val cpuVendor: String,
val cpuEdx: String,
val cpuEax: String,
val cpuEcx: String,
val brand: String,
val hostname: String,
) {
/** Whether the probe reported everything the hash of this activation version is built from. */
fun complete(version: Int): Boolean {
val needed = arrayListOf(boardManufacturer, biosManufacturer)
when {
version <= 1 -> needed += listOf(hostname, osInstallDate, osSerial)
version == 2 -> needed += listOf(osInstallDate, osSerial, volumeSerial, cpuVendor, cpuEdx, cpuEcx)
version == 3 -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEcx)
else -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEax)
}
return needed.all { it.isNotEmpty() }
}
companion object {
fun fromJson(o: JSONObject): Hardware {
fun s(k: String) = o.optString(k).trim()
return Hardware(
boardManufacturer = s("boardManufacturer"),
boardSerial = s("boardSerial").ifEmpty { "None" },
biosManufacturer = s("biosManufacturer"),
biosSerial = s("biosSerial").ifEmpty { "None" },
osInstallDate = s("osInstallDate"),
osSerial = s("osSerial"),
volumeSerial = s("volumeSerial").replace("-", "").lowercase().let { if (it.isEmpty()) it else it.padStart(8, '0') },
gpuPnpId = s("gpuPnpId").ifEmpty { "None" },
cpuVendor = s("cpuVendor"),
cpuEdx = s("cpuEdx").uppercase(),
cpuEax = s("cpuEax").uppercase(),
cpuEcx = s("cpuEcx").lowercase(),
brand = o.optString("cpuBrand"),
hostname = s("hostname"),
)
}
}
}
fun machineHash(hw: Hardware, version: Int): String {
val edxEax = hw.cpuEdx + hw.cpuEax
val edx = hw.cpuEdx.lowercase()
val shortGpu = hw.gpuPnpId.substringBeforeLast('\\', hw.gpuPnpId)
val parts = arrayListOf(hw.boardManufacturer, hw.boardSerial)
when {
version <= 1 -> parts += listOf(hw.hostname, hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial)
version == 2 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
version == 3 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
else -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, hw.gpuPnpId, hw.cpuVendor, edxEax)
}
val text = StringBuilder(parts.joinToString(";")).append(';')
if (version >= 2) text.append(hw.brand.take(47).padEnd(47, '\u0000')).append(';')
val digest = EaCrypto.sha1(text.toString().toByteArray(Charsets.ISO_8859_1))
return if (version < 4) digest.joinToString("") { Integer.toHexString(it.toInt() and 0xFF) } else EaCrypto.hex(digest)
}
/** Version of the game's activation layer, which decides how the machine hash is built. */
fun activationVersion(core: File): Int {
val dll = listOf("activation.dll", "awc.dll").firstNotNullOfOrNull { EaPaths.find(core, it) } ?: return 1
val (major, minor, patch) = productVersion(dll.readBytes()) ?: return 1
return when {
major >= 5 -> 4
major == 4 && (minor > 7 || (minor == 7 && patch >= 6)) -> 3
major == 4 && minor == 7 -> 2
major == 4 -> 1
else -> 0
}
}
/** The activation layer's folder: `Core` beside the game's exe, where EA ships it, or at the game root. */
fun coreDir(game: File, exe: String): File? {
val exeDir = exe.replace('\\', '/').substringBeforeLast('/', "")
val candidates = listOfNotNull(exeDir.takeIf { it.isNotEmpty() }?.let { "$it/Core" }, "Core")
return candidates.firstNotNullOfOrNull { EaPaths.find(game, it)?.takeIf { f -> f.isDirectory } }
}
/** Games built on the 2012-era EA Core client, which want an opaque token rather than a JWS one. */
fun legacyCore(core: File?): Boolean = core != null && EaPaths.find(core, "EACore.dll") != null
private fun encodedSignature(core: File): Boolean = EaPaths.find(core, "activation.exe") != null
internal fun productVersion(pe: ByteArray): Triple<Int, Int, Int>? {
val magic = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte())
var i = 0
while (i + 24 <= pe.size) {
if (pe[i] == magic[0] && pe[i + 1] == magic[1] && pe[i + 2] == magic[2] && pe[i + 3] == magic[3]) {
fun u32(at: Int) = (pe[at].toLong() and 0xFF) or ((pe[at + 1].toLong() and 0xFF) shl 8) or ((pe[at + 2].toLong() and 0xFF) shl 16) or ((pe[at + 3].toLong() and 0xFF) shl 24)
val ms = u32(i + 16)
val ls = u32(i + 20)
return Triple((ms ushr 16).toInt(), (ms and 0xFFFF).toInt(), (ls ushr 16).toInt())
}
i++
}
return null
}
class License(val fields: LinkedHashMap<String, String>, val namespace: String, val signature: String) {
val gameToken: String? get() = fields["GameToken"]?.takeIf { it.isNotEmpty() }
val contentId: String get() = fields["ContentId"].orEmpty()
}
internal fun parseLicense(xml: String, signature: String): License {
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }.newPullParser()
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "").trim()))
val fields = LinkedHashMap<String, String>()
var ns = ""
var depth = 0
var tag = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
depth++
if (depth == 1) ns = parser.getAttributeValue(null, "xmlns").orEmpty() else tag = parser.name
if (depth == 2) fields[tag] = ""
}
XmlPullParser.TEXT -> if (depth == 2) fields[tag] = fields[tag].orEmpty() + parser.text
XmlPullParser.END_TAG -> depth--
}
parser.next()
}
return License(fields, ns, signature)
}
private fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
fun licenseXml(l: License): String {
val order = listOf("CipherKey", "MachineHash", "ContentId", "UserId", "GameToken", "GrantTime", "StartTime", "Nonce")
val body = StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")
body.append("<License xmlns=\"").append(esc(l.namespace)).append("\">")
for (k in order) {
val v = l.fields[k] ?: continue
if (k == "GameToken" && v.isEmpty()) continue
body.append('<').append(k).append('>').append(esc(v)).append("</").append(k).append('>')
}
return body.append("</License>").toString()
}
fun licenseBlob(l: License, encoded: Boolean): ByteArray {
val signature = if (encoded) l.signature.toByteArray() else java.util.Base64.getDecoder().decode(l.signature)
val head = ByteArray(SIGNATURE_SLOT)
System.arraycopy(signature, 0, head, 0, signature.size.coerceAtMost(SIGNATURE_SLOT))
return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray())
}
fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License {
val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes))
val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token)
if (requestToken != null) params += "requestToken" to requestToken
if (requestType != null) params += "requestType" to requestType
val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction"))
if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1<redacted>\"").take(600)}")
val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature")
return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature)
}
/** Licence files for a game about to start, named as the activation layer looks for them. */
fun files(core: File, license: License): List<Pair<String, ByteArray>> {
val blob = licenseBlob(license, encodedSignature(core))
val id = license.contentId
return listOf("$LICENSE_DIR/$id.dlf" to blob, "$LICENSE_DIR/${id}_cached.dlf" to blob)
}
}
@@ -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 } }
@@ -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
@@ -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) }
@@ -0,0 +1,17 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.ea.EaUpdates
@Composable
internal fun EaUpdateButton(installed: InstalledStoreGame, enabled: Boolean) {
val ctx = LocalContext.current
LaunchedEffect(installed.key, installed.sidecar.installVersion) { EaUpdates.check(ctx, installed) }
if (EaUpdates.available[installed.key] == null) return
SecondaryButton(stringResource(R.string.common_update), enabled = enabled) { EaUpdates.start(ctx, installed) }
}
@@ -430,6 +430,7 @@ internal fun GameActions(
openUrl(ctx, item?.storeUrl.orEmpty().ifBlank { GameJoltApi.pageUrl("game", installed.sidecar.id) })
}
}
if (store == Store.EA && installed != null) EaUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null)
if (store == Store.GOG && installed != null) GogMaintenanceActions(installed)
if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item)
if (store == Store.XBOX && installed != null && id != null) {
@@ -915,7 +916,7 @@ private fun AddonRow(name: String, target: WowAddons.Target, a: FrontEndActions)
@Composable
private fun CloudRows(g: Library.SteamGame) {
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON && it != Store.EA } ?: return
val id = g.storeId ?: return
val context = LocalContext.current.applicationContext
var busy by remember(g.gameId) { mutableStateOf<String?>(null) }
@@ -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>()) }
@@ -741,7 +742,7 @@ internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownlo
}
}
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available } == true ||
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available || it in com.droiddeck.launcher.stores.ea.EaUpdates.available } == true ||
e.source == Store.GOG.id && (e.item?.id ?: e.game?.storeId)?.let { GogMaintenance.checks[it]?.state } == GogMaintenance.State.AVAILABLE
@Composable
@@ -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"),
@@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
@@ -48,6 +49,7 @@ internal fun sourceLabel(source: String): String = when (source) {
Store.ITCH.id -> Store.ITCH.label
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
Store.BATTLENET.id -> Store.BATTLENET.label
Store.EA.id -> Store.EA.label
else -> stringResource(R.string.source_added)
}
@@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
null -> return base
}
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
-1
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>
@@ -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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
@@ -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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
-1
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>
@@ -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>
@@ -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>
@@ -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>
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more