Compare commits

...
94 Commits
Author SHA1 Message Date
MaxsTechReview 3bb279f635 fix(appimage): emulators and other AppImage programs get the game GPU setting (#2288)
The session sets TU_DEBUG=sysmem for the Steam client's interface and hands
games their own value in DROIDDECK_GAME_TU_DEBUG, which droiddeck-game-env
applies to Steam launches (#1188). droiddeck-appimage-run never did, so
emulators and other AppImage programs rendered every pass through system
memory instead of Turnip's per render pass choice.

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

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

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

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

* ci: ignore fork runner heartbeats from the future

* ci: target the fork runner by its own label

* Read the fork runner heartbeat from a public gist

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

* docs: add 0.7.0 release note media

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

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

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

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

* docs: swap in the new Syncthing screenshot

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

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

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

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

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

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

* docs: add KeegantheVeganat0r as a first-time contributor

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

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

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

* docs: center 0.7.0 release media

* docs: credit The412Banner for Mali support in 0.7.0 notes

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

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

* docs: use the Duo photo for the swapped screens

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

* docs: drop the swap screens GIF

* docs: keep a blank line before each release image

* docs: tweak 0.7.0 release notes

* docs: move smaller 0.7.0 items to improvements

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

* docs: pair the swap screens and KDE desktop media

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

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

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

* docs: add recent merged PRs to the 0.7.0 notes

* docs: auto component updates follow DroidDeck-Proton only

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

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

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

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

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

---------

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

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

* ci: keep PowerVR's verified downloads between runs

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

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

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

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

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

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

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

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

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

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

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

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

* A resize also ends a pending two-finger touch

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* gamescope: read the pointer constraint under the wlserver lock

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Diagonal key names are drawn on sticks only, so a D-pad bound just on its
diagonals no longer offers a label it can't show.
2026-11-07 21:04:34 -05:00
Kurt Himebauch ef46c011db fix: keep the bottom-screen menu and layout editor open when the screen turns off (#2247) 2026-11-07 20:11:20 -05:00
MaxsTechReview 5b2f7a9a6d fix(storage): a Second library on a raw /mnt/media_rw path is used again (#2245)
fix(storage): check a raw /mnt/media_rw library against its storage volume

A Second library picked as /mnt/media_rw/<volume>/... was always treated as
unplugged, because Android only reports a volume's state for its /storage
path. Sessions then ran with internal storage only and the library's games
were missing. Map the path to its /storage form before the lookup, reusing
UsbLink.storagePath, and drop the copy in SteamLibraries.
2026-11-07 19:32:13 -05:00
Kurt Himebauch 10cfc6c6b2 feat: let users turn off the low-storage warning (#2244) 2026-11-07 19:09:51 -05:00
MaxsTechReview 4948f73912 test: skip the unscrubbable session log test when file permissions are not enforced (#2241) 2026-11-07 18:37:44 -05:00
Kurt Himebauch d29a1a2dc2 ci: build and test on root self-hosted runners (#2237) 2026-11-07 17:02:19 -05:00
MaxsTechReview 8fa30b99f9 fix(input): restore the controls, panels and settings #1439 removed (#2238)
* fix(input): restore on-screen touch behaviour: slide between buttons, mouse pass-through, keyboard on release, overlay haptics, stick reach and hit areas

* fix(dual-screen): restore second-screen bars, swapped Terminal and Deck panel tabs

* fix(input): fit on-screen layouts to the real view size, cutout, Steam + QAM mode and unfolded strip

* fix(input): unfolded strip keeps press haptics

* fix(input): restore layout import/export, element editor rows and layout tools

Bring back .icp layout import and export on the saved layouts page, the
per-element image, opacity and label controls, a uniform size slider and
reset positions in the layout editor, and non-destructive Steam, QAM and
keyboard button toggles stored on the layout style. Remove strings left
unused by the input profile unification.

* fix(input): keep hit slack to gamepad-shaped buttons and fit touch tests to saved layout geometry

* fix(input): button size regenerates the gamepad and scales spacing

A generated gamepad now stores its button size and is regenerated at that
size, so spacing follows the radius and the layout keeps regenerating on
rotation. Edited layouts scale positions from their anchor edge or centre
so gaps grow and shrink with the controls.

* fix(input): button size rescales moved gamepads around the generator

Gamepad layouts that no longer match the generated positions exactly were
scaled element by element from screen thirds, which split the face buttons
and D-pad across anchors. Gamepad controls now take the generator's
positions at the new size plus each control's own offset from the
generated position, so groups keep their shape and spacing.
2026-11-07 16:36:23 -05:00
MaxsTechReview a33954b8bd perf: answer Steam's missing-file checks on SD and USB libraries from the directory cache (#2212)
* perf: answer Steam's missing-file checks on FUSE libraries from the directory cache

Before creating and committing each file, the Steam client checks whether it
already exists under its exact, lower-cased and folded spellings. On an SD or
USB library reached through Android's FUSE layer every one of those misses is a
round trip to MediaProvider (Brawlhalla: about 65,000 misses, 92 of the 161 s
of preallocation and 60 of the 93 s of commit).

The client already caches the listings of its steamapps directories on FUSE and
keeps them exact through its own changes, inotify and a short TTL. access() now
answers a name that is absent from a live listing of its directory without
asking the filesystem. Directory paths match case-insensitively only when the
library's filesystem folds case, and names compare case-insensitively, so an
existing file is never reported missing.

* fix: reuse a listing for a differently cased folder only when the whole path folds case

Checking that steamapps alone folds case let a listing of one folder answer
for a different folder below it whose name differs only in case. The fill
now compares the listed folder with its fully case-swapped path below
steamapps, so a listing is reused across case only when the filesystem
folds every level.

* fix(dircache): match a folded listing only below the same prefix and skip it during a tracked change

* fix(linuxfs): a folder renamed or removed above a cached listing drops that listing

* fix(linuxfs): watch the folders above a cached listing so a rename or removal by any process drops it

* fix(linuxfs): also watch the folders above steamapps, and stop a stuck probe from hanging the tests

* fix(linuxfs): a listing under a symlink or an unwatchable folder no longer answers absent lookups

* fix(linuxfs): a symlink check that cannot be watched also stops absent answers

* fix(linuxfs): a differently cased lookup reuses a listing only for the same folder
2026-11-07 16:36:15 -05:00
Kurt Himebauch 3526309f88 feat(session): setting to choose the Android keyboard for game text fields (#2236) 2026-11-07 16:19:40 -05:00
MaxsTechReview be0fe35271 fix(proot): let the fast path fall back when Android 10 refuses statx (#2224) 2026-11-07 15:48:44 -05:00
MaxsTechReview 7f0427b830 fix(input): L2/R2 keys stop overriding analog trigger axes (#2205)
fix(input): stop L2/R2 keys from overriding analog trigger axes

Pads such as the GameSir X2 Pro send KEYCODE_BUTTON_L2/R2 alongside
their analog trigger axes. Each key-down and every key repeat set the
trigger to full, and the next motion event set it back to the axis
value, so a held trigger jumped to full. Android repeats only the most
recently pressed key, so with both triggers held one side kept being
forced to full while the other tracked its axis.

When a device reports an analog axis for a trigger side (LTRIGGER or
BRAKE, RTRIGGER or GAS, or the wired Xbox Z/RZ layout), its key no
longer drives that trigger. Key repeats never change a trigger value.
Pads with digital-only triggers keep the key behaviour unchanged.
2026-11-07 15:48:41 -05:00
MaxsTechReview 2476c824f6 fix(dual-screen): docked handhelds switch to single-screen mode (#2232)
* fix(dual-screen): docked handhelds switch to single-screen mode

With Dual screen on Auto, a dual-screen handheld connected to a wired
external display (dock, HDMI or USB-C monitor) no longer puts the menu,
tabs or session controls on the built-in bottom screen, which nobody can
see while it sits in a dock. The launcher and sessions use the single-screen
layout on the main screen, which the external display mirrors. A bottom
panel opened automatically closes once when the dock is connected and comes
back after undocking.

XR glasses, virtual and overlay displays and switched-off displays do not
count as a dock, and Dual screen set to On keeps the bottom screen.

* fix(dual-screen): only an automatic bottom panel closes on docking, and glasses are told apart from the dock display

* fix(dual-screen): keep the automatic-panel state across activity recreation and reconcile docking before settling the panel
2026-11-07 15:48:17 -05:00
MaxsTechReview d74575ed7f fix(components): DXVK Auto on Adreno 6xx keeps DroidDeck-Proton on DXVK-gplasync 2.7.1 (#2226)
* fix(components): DXVK Auto on Adreno 6xx keeps DroidDeck-Proton on DXVK-gplasync 2.7.1

* fix(components): DXVK Auto says so when the pinned DXVK could not be installed

* fix(components): a DXVK the player picked is shown as their choice, not a failed pinned install
2026-11-07 15:48:14 -05:00
MaxsTechReview e4d4096342 fix(drivers): a driver repo that fails to answer no longer changes the Auto pick (#2233)
* fix(drivers): a driver repo that fails to answer no longer changes the Auto pick

The release check reads every driver repo and saved the list even when one
of them failed, so that repo's drivers vanished until the next good check.
When GitHub rate-limited or dropped the DroidDeck driver repo, Auto on an
Adreno 6xx saw no DD-Turnip release, switched to Banner's Turnip, and then
switched back after the next clean check.

A repo that fails now keeps its releases from the last good check, while
still being recorded as failed. A repo that answers and no longer lists a
driver still drops it.

* test(drivers): carry-over is checked through refresh with one repo failing

* fix(drivers): drop a repo's partial results when its answer fails and keep the refresh test offline
2026-11-07 15:48:09 -05:00
KeegantheVeganat0r 11218f0b24 perf(hud): reduce Strip rendering cost with bitmap graph and logo (#2235)
perf(hud): rasterize Strip graph and logo separately

Replace PR2194's whole-View software layer with a hardware-accelerated
Strip that rasterizes its graph and logo into small reusable bitmaps.
Keep the 66 ms graph invalidation cap, clear stale graph pixels, and
refresh cached logo colours when the theme changes.

Include the comparison method and six sanitized benchmark samples.
With matched Steam navigation, the hybrid averaged 12.2% less UI-thread
CPU and 34.0% lower draw p95 than the PR2194 software path. App-process
CPU averaged 5.9% lower in this run; whole-device and battery savings
are not established. Current-build physical flicker confirmation is
recorded separately from timing validation.

Validation: all 12 DroidDeckHudPanelTest checks passed; comparison APK
built and six alternating device samples completed with matched input
counts, no focus loss, thermal status 0, and no frame metric report drops.
2026-11-07 15:41:46 -05:00
Kurt Himebauch d9bf6d8a17 feat: move UBWC compatibility from Components to Settings (#2008)
* feat: move UBWC compatibility from Components to Settings

* fix: update driver menu tests for the moved UBWC restart check

* fix: load saved UBWC mode at startup and block Settings restart during runtime operations
2026-11-07 15:39:55 -05:00
MaxsTechReview 041c3b1b55 ci: run compile jobs on the org workstation runner when it is idle (#2234)
* ci: run compile jobs on the org workstation runner when it is idle

* ci: pick the compile runner right before each heavy job

* ci: cover the runner picks in the job dependency test
2026-11-07 15:00:39 -05:00
Kurt Himebauch e5434117f2 fix(input): controller navigation works across the Controller tab and profile menus (#2231)
* fix(input): controller navigation works across the Controller tab and profile menus

* fix(input): dialog choice rows expose their checked state
2026-11-07 14:37:00 -05:00
MaxsTechReview b474cba79e fix(dual-screen): bring back the trackpad and Deck panels on the second screen (#2229)
Keyboard + trackpad and Deck grips + trackpads are second-screen panels again, rendering the stock layouts without changing the input profile. The last panel is remembered separately for desktop and game sessions; desktop sessions start on the trackpad. A Deck panel without the Deck pad falls back to the default. Stock panels left Steam blue return to the neutral look once.
2026-11-07 14:36:30 -05:00
Kurt Himebauch ffb76240fd fix(fex): show which preset Use Default resolves to (#2225)
* fix(fex): show which preset Use Default resolves to and rename FEX defaults to Stock FEX

* test(components): the FEX preset chip names its resolved preset instead of Use Default

* fix(fex): the game settings menu keeps Use Default beside the inherited preset

* fix(fex): inherited preset chip follows the current Default preset
2026-11-07 14:22:00 -05:00
MaxsTechReview 875f8e3ce2 feat(wow): download addons from WoWInterface and open CurseForge in the browser (#2219) 2026-11-07 13:09:21 -05:00
MaxsTechReview 5446497301 fix: uninstalling a Flatpak app that Flatpak no longer has removes its folder (#2216) 2026-11-07 12:46:52 -05:00
MaxsTechReviewandKurt Himebauch 98a670fb81 fix(input): classic on-screen gamepad look, quick hide toggle and adaptive sticks switch (#2215)
* fix(input): bring back the classic on-screen gamepad look, quick hide toggle and adaptive sticks switch

The unified input surfaces (#1439) drew the built-in gamepad as large
rounded boxes labelled with control names, swapped the compact keyboard
button for a wide pill, dropped the bottom-left show/hide toggle and left
the Adaptive sticks setting with no effect once a layout was migrated.

Gamepad elements are round again with shoulder pills, D-pad arrows, the
old glyph labels and the tint-derived fill and outline. The keyboard is a
small round button beside a restored quick show/hide toggle. Stick touch
areas reach 25% further outward while keeping their inner edge, and the
session menu gets an Adaptive sticks switch that writes the active
layout's sticks. Libraries saved by the first unified build are restyled
once without touching user edits or other layouts.

* fix(input): overlay keyboard opens on the main screen and the D-pad matches the face buttons

A keyboard command now carries the role of the surface that sent it, so the
overlay's keyboard button opens the main screen keyboard as before the
unified surfaces, while lower-screen and controller bindings still use a
second screen. The D-pad buttons take the face buttons' size and spacing,
and an untouched saved D-pad is resized around its own centre. Classic
outlines sit where the old ones did and shoulder labels use their old size.

* fix(input): keep edited D-pads and keyboard buttons, harden the quick and adaptive toggles

Resize a saved D-pad only while its four buttons keep their original geometry, and
replace the old keyboard button only while its name and shape are untouched.
The quick toggle needs its own press before a release toggles it, and a cancelled
touch does nothing. A rejected library write no longer crashes the session or
changes the controller preference, and the Adaptive sticks switch shows whenever
the built-in gamepad is the surface in use.

* fix(input): show the adaptive sticks switch while the unfolded controls are up

* fix(input): run the gamepad restyle as library version 10 and draw the quick toggle in Gray

* fix(input): show the adaptive sticks switch whenever the built-in gamepad is the surface in use

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-11-07 12:42:45 -05:00
MaxsTechReview a123af7c13 fix(script-run): run x86 programs a native script starts through FEX (#2217) 2026-11-07 12:27:24 -05:00
MaxsTechReview 6026d72435 fix(input): restore the neutral look of the bottom-screen controls (#2221) 2026-11-07 12:06:03 -05:00
MaxsTechReview d5f3d1a0d5 fix(proton-run): follow a removed DroidDeck-Proton build to the newest one (#2223)
Battle.net, store games and desktop programs start through droiddeck-proton-run
without the Steam client. When the Proton auto-update replaced the DroidDeck-Proton
build that Steam's default or a title's mapping named, the name no longer matched
an installed tool and the run fell back to Valve's Proton Experimental ARM64, which
lacks DroidDeck-Proton's patches. World of Warcraft then hung at launch.

A removed DroidDeck-Proton build now resolves to the newest installed one, the same
successor steam-compatibility moves Steam's mappings to.
2026-11-07 11:37:17 -05:00
MaxsTechReview 4cb3a89483 fix: library scan and download cancel crash on Android 10 (#2204)
* fix: library scan and download cancel crash on Android 10

The storage scan read StorageVolume.getDirectory() for every volume to
build its key, but that method only exists from Android 11, so on
Android 10 the steam-library-scan thread died with NoSuchMethodError.
The directory is now read once behind the existing Android 11 check and
reused for both the key and the candidate path; older versions fall
back to the uuid, description and /storage scan as before.

Cancelling a download closes the response stream on a background
thread. The platform okhttp can throw IllegalStateException
("Unbalanced enter/exit") from close() while a read is still in
flight, which was uncaught and killed the app. That close now ignores
IllegalStateException as well as IOException; other failures still
surface.

Fixes #2200

* test: wait for the cancel task to finish instead of polling its thread state

The cancel-close tests decided the close task was done from the worker's
thread state, which can read WAITING while the pool is still cleaning up
after a thrown exception. A close failure that escapes ends its worker
thread, and Java runs the uncaught handler before the thread ends, so the
surfacing test now joins that thread. The suppression test hands the
cancel executor more work until it runs on the same thread, which only
happens once the close task has returned without throwing.

* test: watch the close task finish instead of probing which worker runs next

A cached pool can start a second worker, so a probe task may never land
on the thread that ran the close. The suppression test now waits until
the pool has no task running and that thread is back to waiting for
work, a state a worker that is exiting after a thrown exception never
enters.
2026-11-07 11:16:08 -05:00
Kurt Himebauch 22a675a664 Unify controller and screen input profiles for game and desktop sessions (#1439)
## Problem and intent

Desktop sessions need configurable mouse, keyboard and application
actions from a controller. On dual-screen devices, a lower-screen
trackpad may already handle the pointer, leaving the physical controls
available for other assignments. This PR gives physical controllers and
local touch controls a shared profile/action model instead of separate
configuration paths.

This follows the centralization direction discussed in
[#1202](https://github.com/Droid-Deck/DroidDeck/pull/1202#issuecomment-6299647180)
and addresses the controller-input portion of #860 and #1356. Those
discussions are context, **not prior maintainer approval** of this
implementation. I would appreciate architectural feedback and guidance
on splitting or coordinating this work if that would make review easier.

## Profiles and configuration

- Controller stays within the existing Setup tabs. Game and Desktop each
have a default-profile selector, followed by one profile list and device
settings.
- Named profiles reference three independently reusable parts: Physical
controller, Main screen and Second screen. Layout geometry and action
bindings are stored separately. Profiles can share physical controls
while assigning different touch actions.
- The session menu selects a complete profile, or expands to mix the
three parts. A mixed selection can be saved as a new profile; Game and
Desktop retain separate selections.
- Both screens use the same full-size editor, with add, move, resize,
snapping, undo and shared properties. The main-screen editor has
movable, collapsible tools and is transparent above a running session.
When editing the secondary display, its entire area is the canvas and
the main display hosts tools/properties.
- The old fixed gamepad, fixed Deck controls and separate Screen keys
editor are replaced with editable presets and the shared renderer.
Existing positions, bindings, shapes, symbols, imported images and
toggle behavior migrate; legacy Screen keys become additional profiles
without replacing the user's selected profile. Migration retains a
backup before removing old preferences.
- Saved layouts have thumbnails and screen targeting. Actions include
compatible native output/remapping, keys/combinations, mouse buttons,
pointer movement, vertical/horizontal scrolling, directional keys,
existing menu/keyboard commands, and disabled. Buttons can use symbols,
shapes and toggle behavior.

## Architecture and latency


```mermaid
flowchart LR
    Library[Profiles + reusable layouts and bindings] --> Router[InputRouter]
    Physical[Physical controller adapters] --> Router
    Touch[Main + secondary InputSurfaceView] --> Sources[Owned pad / Deck sources]
    Sources --> Router
    BLE[Steam Controller BLE adapter] --> Router
    Router --> Native[Existing controller publication]
    Native --> Steam[Steam Input / guest controller]
    Router --> Held[Shared key and mouse ownership]
    Held --> Guest[Existing guest input outputs]
    Router --> UI[Existing menu / keyboard hosts]
```

Bindings resolve when the profile changes, outside event dispatch.
Native forwarding has no additional frame/timer queue; identity routes
reuse existing state/reports. Only synthesized continuous stick
pointer/scroll motion uses a frame callback. One source releasing a
key/button does not release another source's hold. Menu, profile,
suspension and removal transitions release old outputs and gate held
controls until neutral.

Steam continues to own game-specific mappings and action sets. Steam
Mobile Touch and VR retain their specialized paths. Raw device adapters
remain responsible for transport/calibration and must join routing
before publishing consumed controls; this does not claim that every
adapter has been unified.

Historical Thor measurements on the native Game path found
kernel-event-to-guest-observer upper-bound medians of **3.061 ms
baseline versus 2.999–3.403 ms candidate**, with a subsequent
installed-APK check at **3.039 ms / 300 matched edges**. These are
relative software-path measurements, not button-to-photon latency. Some
tails increased, simultaneous BLE contention remains unmeasured, and
**this UI revision has not had a new latency measurement**. The previous
cursor fix changed visual update intervals from roughly 33.3 ms to 16.7
ms without changing sensitivity or adding smoothing.

[Full design, migration and
boundaries](https://github.com/Delete228/DroidDeck/blob/codex/unified-controller-input/docs/development/unified-input-proposal.md).

## Validation

Updated through upstream 542bbf89c; current PR head 08526f828. The
latest CodeRabbit review generated no actionable comments.

- **1,017 tests passed in 133 suites after the latest upstream merge**,
with no failures, errors or skips: all input/UI tests, global reset,
component updates, PiP policy, runtime auto-update and Proton menu
auto-selection. The pre-merge review-fix run passed **967
input/UI/global-reset tests**. The subsequent second-screen refresh fix
passed **35 focused surface/presentation tests**, including three new
state-preservation regressions.
- Full local run before the final review follow-ups: **3,598 tests,
3,573 passed, 25 failed**. The 25 are unchanged upstream macOS
limitations: log-export tests requiring SecureDirectoryStream (24) and a
case-sensitive WoW add-on test (1). The global-reset regression also
passes. Earlier disk-exhausted attempts were discarded; the completed
run bounded temporary resources with one worker and JVM recycling,
without excluding tests or changing repository test configuration.
- APK build, runtime/session assets, native dependencies, alignment and
v1/v2/v3 signatures passed. Installed over the separate Input Test app
on **AYN Thor** with its environment retained. Local packaging omits
Rust store engines; the [full Build APK workflow for 08526f828
passed](https://github.com/Droid-Deck/DroidDeck/actions/runs/42295403480),
including Android/JVM, Python/native, Rust store-engine and Xbox checks.
- Review follow-ups keep migration IDs stable, preserve unreadable
supported-version libraries before recovery, retain forward-version
protection, release keyboard owners on focus loss, handle unavailable
presentation displays, and allow controller confirmation/cancellation
after key capture. Deck router cleanup checks ownership; BLE teardown
synchronously blocks late routing/publication, and shared Triton slot
ownership protects a replacement controller from stale instances. Legacy
Select-to-Steam migrates to Desktop without replacing saved bindings;
mouse backend transitions are serialized with publication. Profile
refresh now updates custom controls in place, preserving
terminal/dashboard state, the field keyboard and WoW chat. No physical
BLE-controller test or new latency measurement was performed.
- The latest 08526f828 APK is installed on Thor. Profile preferences
stayed byte-for-byte identical and the retained Steam environment
reached READY at 1280×720 / 60 Hz. The earlier capture-dialog fix was
also exercised on Thor. APK SHA-256:
`ed6ef888648468b4ebeb94e8f1ae74a195bb1f0a3f27e4fb3646d5462a4beb6b`.
- Upstream integration preserves the new WoW chat panel,
platform-pointer and PiP fixes. The fast-double-tap stick-click rule and
its six regressions were adapted to the shared renderer; WoW field
selection also preserves Ctrl held by a profile.
- Latest Thor UI pass: existing Setup navigation, profile/layout
previews, action persistence, centered element creation, main-screen
editing over Steam, secondary canvas with primary tools/properties, and
X returning from the docked menu to controls.
- Across the documented device sessions, the user tested native physical
buttons/sticks/triggers, Deck pads/grips, desktop
pointer/clicks/scrolling/arrows, Android keyboard with physical input,
Ctrl+F and Ctrl+A/X/V/S, simultaneous physical/touch holds, menu release
and Home/resume. These historical checks are distinguished from the
latest UI-only pass.

[Revision-specific results, APK hashes and
limitations](https://github.com/Delete228/DroidDeck/blob/codex/unified-controller-input/docs/development/unified-input-validation.md).

## Coordination and remaining work

- #1202 (jmarti326): shortcut capture/device identity and the
shared-assignment discussion informed this work.
- #1305 (kingokksa): shared PC-key definitions were adapted from that
PR; this does not absorb its entire feature set.
- #1414, #581, #1226 and #1150 remain relevant for external-device
input, calibration, keyboard routing and DualSense integration. The
DualSense coordination discussion in this PR describes adapter
boundaries and duplicate-stream suppression.

Import/export, automatic per-app selection, macros, multi-button
activation chords and automatic text-field keyboard detection remain out
of scope. A single binding emitting a key combination is implemented.

The configuration UI is now implemented and exercised on Thor. I am
happy to continue UX/accessibility/localization refinements here before
merge, or in follow-up PRs, according to maintainer feedback. The main
review questions remain the shared model, source ownership/lifecycle
contract, migration policy and integration with other input work.


## UI walkthrough on AYN Thor

Actual captures from the installed Input Test build. In paired images,
the main display is above the second display. The test application name
and local build badge are not production branding changes.

<details>
<summary>Controller settings: separate defaults for Desktop and Game,
followed by the profile library.</summary>

<img width="960" alt="Controller settings: separate defaults for Desktop
and Game, followed by the profile library."
src="https://github.com/user-attachments/assets/a4c156d6-9ded-43df-b0f8-e5f34028806d"
/>

</details>

<details>
<summary>Physical controller: bindings grouped by control type, with
each current action visible.</summary>

<img width="960" alt="Physical controller: bindings grouped by control
type, with each current action visible."
src="https://github.com/user-attachments/assets/a8a840e3-19ac-473f-9f55-b06ae9c637b5"
/>

</details>

<details>
<summary>Reusable layouts: bounded previews and separate screen
targets.</summary>

<img width="960" alt="Reusable layouts: bounded previews and separate
screen targets."
src="https://github.com/user-attachments/assets/cf0cf7d1-42f3-46cd-b50b-f01e1fa8ed41"
/>

</details>

<details>
<summary>Main-screen editor over the live Steam UI, with the movable
tools expanded. The background is Steam artwork, not a running
game.</summary>

<img width="960" alt="Main-screen editor over the live Steam UI, with
the movable tools expanded. The background is Steam artwork, not a
running game."
src="https://github.com/user-attachments/assets/0781f7c8-fd10-47e5-8a23-817f945a6289"
/>

</details>

<details>
<summary>Dual-screen editor: tools and selection on the main display,
full-size placement canvas on the second display.</summary>

<img width="720" alt="Dual-screen editor: tools and selection on the
main display, full-size placement canvas on the second display."
src="https://github.com/user-attachments/assets/f13d4ef6-be6d-4d95-9d42-e047e0a63b8a"
/>

</details>

<details>
<summary>The selected lower-screen trackpad stays visible while its
properties open on the main display.</summary>

<img width="720" alt="The selected lower-screen trackpad stays visible
while its properties open on the main display."
src="https://github.com/user-attachments/assets/26baf597-f037-419e-9601-e48f086c0d4f"
/>

</details>

<details>
<summary>Session menu: expand Profile to select the physical,
main-screen and second-screen parts independently.</summary>

<img width="720" alt="Session menu: expand Profile to select the
physical, main-screen and second-screen parts independently."
src="https://github.com/user-attachments/assets/27ac4d9c-68b3-4257-8353-23104e2343de"
/>

</details>
2026-11-07 08:32:50 -05:00
MaxsTechReview ca6c409436 fix(drivers): built-in Auto picks match the published table on Adreno 6xx and 8xx (#2151) 2026-11-07 08:25:08 -05:00
Jordan Y 7c429f3a26 fix: droiddeckctl pacing reads timestats dumps with Windows line ends (#2186) 2026-11-07 08:23:06 -05:00
Kurt Himebauch aba55031c3 perf: answer chown and bound thermal paths through the proot fast path (#2203) 2026-11-07 01:08:06 -05:00
MaxsTechReview 18c7d56409 fix: Auto components update only DroidDeck-Proton and delete old builds (#2193) 2026-11-06 20:43:44 -05:00
MaxsTechReview 0b4bc827e7 fix(battlenet): stop Battle.net's pages playing videos on the CPU (#2195) 2026-11-06 20:39:37 -05:00
KeegantheVeganat0randKurt Himebauch a552f1e293 fix(hud): isolate strip rendering in a software layer (#2194)
* fix(hud): skip relayout when size is unchanged and cap graph redraws

* fix(hud): limit the redraw and relayout changes to the strip

* fix(hud): draw the strip background from a cached bitmap like the panels

* fix(hud): drop the strip-only redraw cap and relayout skip

* fix(hud): the strip graph redraws at most 15 times a second

* fix(hud): isolate strip rendering in a software layer

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-11-06 20:23:10 -05:00
MaxsTechReview 066834d17d feat: compress idle game memory before low-RAM devices run out (#2187) 2026-11-06 19:49:06 -05:00
Delete228 aff9738748 Preserve second-screen choices across partial selections and migration retries
Track explicit whole-profile choices, including Auto, separately from inherited profiles so physical and main-screen overrides do not suppress legacy panel migration. Persist this distinction in library version 8 and read older selection records compatibly.

Keep saved Deck panels during unavailable-controller fallbacks and retain legacy preferences when migration fails. Add regressions for all three review findings, explicit Auto selection and older saved libraries.

Validation: all 992 input/UI tests passed.
2026-11-07 02:44:47 +03:00
Kurt Himebauch f33d5b9e09 fix(components): auto updates follow DroidDeck-Proton only (#2190) 2026-11-06 18:41:37 -05:00
Delete228 281c2aea1b Preserve custom profiles when opening second-screen fallbacks
Only migrate stock controls for explicitly saved legacy panel modes. Automatic fallbacks retain the active profile layout and bindings without persisting an override.

Add session regressions for automatic fallbacks, unavailable panels, explicit legacy migration and existing quick selections. Validation: all 986 input/UI tests passed.
2026-11-07 02:29:31 +03:00
Delete228 c2d7765619 Fix desktop profile selection and simplify default touch layouts 2026-11-07 02:10:26 +03:00
MaxsTechReview 250f80fbb5 fix: stop switching between display modes with the same refresh rate (S26 lag and heat since 0.6.7) (#2192)
fix: stop switching between display modes with the same refresh rate
2026-11-06 17:18:24 -05:00
Delete228 08526f8283 Preserve second-screen state when refreshing input profiles 2026-11-07 00:16:45 +03:00
Delete228 f44826ae3a Merge main and retain unified input behavior 2026-11-07 00:01:20 +03:00
Delete228 31b8adbb55 Guard shared input slots and mouse backend transitions 2026-11-06 23:56:30 +03:00
MaxsTechReview 542bbf89c3 fix: launch Battle.net and store games from any game page tab (#2185)
* fix(library): Keep one Play request per game and show Starting while it prepares

* Track launch preparation per game

* Keep each game preparing until its own worker finishes

* Drop store lookups that finish after the activity ends
2026-11-06 15:31:09 -05:00
Kurt Himebauch e1987ca785 chore: pin gamescope to the DroidDeck-Components p22 release (#2183) 2026-11-06 14:35:10 -05:00
MaxsTechReview a81da1e476 feat(components): Auto updates for FEX, DXVK and VKD3D-Proton (#2156)
* feat(components): Auto updates for FEX, DXVK and VKD3D-Proton

Each component gets an Auto or Manual mode on the Default Components page.
Auto installs the newest DroidDeck-Components nightly into every Proton that
ships the component, matching each Proton's ARM64EC or x64 flavour, keeps the
current and replaced builds, and removes older builds it downloaded.

A game that ran on the previous build and exits within 30 seconds twice on the
new one rolls the line back; the failed build is skipped until a newer one is
published. Existing manual choices keep that component on Manual, and DXVK
stays on Manual while a GPU rule pins its version.

* fix(components): Auto notes per line, Original label and first-update rollback

Boot history on the Original is recorded before the first update so that
update can roll back, rollback notes stay with their own component, the
Original shows by name, and new Protons get the current build even when no
newer one is published.

* fix(components): Auto owns only its own downloads and follows in-place Proton updates

Auto deletes only builds it downloaded, re-swaps a Proton whose version
changed under an existing choice, reports a missing nightly listing as a
failed check, writes its state atomically so the page can read it without
waiting on a download, and words the rollback and download messages to
match what it does.

* fix(components): Auto keeps a rollback and reads updated Protons' own files

After a rollback only a build newer than every rejected one is taken, so an
older build still listed in the nightly cannot replace the restored one.
Flavour detection treats a choice saved for an older Proton version as not
applied, so a Proton Steam updated in place is read from its own files and
re-swapped. A failed state rename falls back to a direct write and is logged.

* fix(components): Keep the last good Auto state when a save fails

* fix(components): Serialise Auto state reads and writes

* Install the Linux runtime by itself on first launch

The runtime auto-update skipped devices with no runtime, so FEX, Proton,
DXVK and VKD3D waited for the first Steam launch to install it. Claim a
first install when the device never had a runtime, on a usable network,
outside battery saver and with a supported GPU, and check Proton as
soon as the runtime is ready. A runtime the user removed stays removed.

* fix(components): Check components as soon as Proton is installed

The Auto check stamped its hourly timer even when no Proton was
installed yet, so a fresh device waited an hour after Proton arrived.
Skip the stamp until a Proton exists and run the check right after a
Proton install finishes.

* fix(components): Apply stored builds on every check and retry unfinished rollbacks

A held or failed download no longer skips applying the stored build or
stamps the hourly timer. Stored builds are applied on every check, so a
new Proton is reconciled inside the hour, and a check requested while
one runs is queued. A rollback that could not restore or swap a Proton
is retried and reported only once it finishes. Removing the runtime
marks it as seen so it is not installed again, and the failed-save test
skips where the process can still write read-only files.

* fix(components): Keep rollback restores pending until applied and save state on failure

* fix(components): Hold newer builds until a pending rollback is applied

* fix(components): Drop pending restores for removed Protons

* fix(components): Never auto-install a non-ARM64X build over a Proton's own ARM64X DXVK or VKD3D

* fix(components): Read probe archives outside the lock and block auto updates while the original build is unknown

* fix(components): Require ARM64X when any Proton on the line needs it, and before each swap
2026-11-06 13:56:34 -05:00
MaxsTechReview 116728917f fix(input): Keep the Android pointer hidden over the game after hovering session menus (#2176)
* fix(input): Keep the Android pointer hidden over the game after hovering session menus

* fix(input): Defer the overlay pointer icon from a container, since ComposeView is final
2026-11-06 13:56:30 -05:00
MaxsTechReview 5f180d909b fix(input): Click a touch stick only on a fast double tap (#2173)
* fix(input): Click a touch stick only on a fast double tap

* fix(input): Count the release position in stick tap travel

* fix(input): Treat cancelled pointer-ups as cancelled stick touches
2026-11-06 13:56:25 -05:00
Kurt Himebauch 88acdbc3ab ci: publish gamescope builds to DroidDeck-Components and never replace a tag (#2181) 2026-11-06 13:36:56 -05:00
Kurt Himebauch 42b2810822 ci: retry PR test build cleanup and sweep leftovers every six hours (#2180) 2026-11-06 13:36:52 -05:00
Kurt Himebauch 1001bc00af fix: PiP scales the full-size output instead of shrinking it (#2174)
* fix: PiP scales the full-size output instead of shrinking it

* fix: hide the performance HUD as PiP starts so it does not flash at full size
2026-11-06 13:24:37 -05:00
MaxsTechReview 69c6c14f3f fix(wow): Clear the game field selection after reading and sending chat (#2175)
* fix(wow): Clear the game field selection after reading and sending chat

* fix(wow): Skip the field cleanup when the chat request has closed
2026-11-06 13:17:13 -05:00
MaxsTechReview 6060f2288f feat(wow): voice type and keyboard chat bar for World of Warcraft (#2171)
* Add voice and keyboard chat input for World of Warcraft

Open a chat bar with an editable text field when a WoW client focuses
a text box (reported by Proton's winex11 IME context hook through
agent/wine-ime.json). First use asks for Voice type or Android keyboard
with a remember toggle; the choice is also in the side menu's Keyboard
group while a WoW client runs. Voice type uses the device's speech
recognition service. On dual-screen devices the bar and keyboard open
on the second screen. Send types the text and presses Enter; Cancel
presses Escape.

* Cover the Arm64 WoW client in the chat match test

* Give the second-screen chat bar the session's result and back owners

* Open the WoW chat bar with the game field's current text

When the bar opens, select and copy the game's text field and fill the
bar with it through the session clipboard bridge, then put the guest
clipboard back. Send selects the field first, so the bar's text
replaces what was there instead of being appended.

* Keep WoW chat consistent across screen changes and failed field reads

Close the bar when the game field closes even after the program name
is cleared, move the bar to the top screen when the second screen drops
it, and drop any pending second-screen keyboard request when the bar
opens. A field read that gets no copy of its own leaves the game text
unselected and Send then types without replacing it. Ctrl is only
pressed and released when nothing else holds it, and a pending read
ends when the clipboard bridge stops.

* fix(session): Close WoW chat for other programs and keep the game cursor on cancelled reads
2026-11-06 12:17:37 -05:00
Delete228 915f373d82 Harden input migration and session lifecycle
Keep migration binding IDs stable, preserve unreadable libraries before recovery, and retain forward-version protection. Release keyboard owners on focus loss, handle unavailable settings displays, and allow controller confirmation or cancellation after physical key capture. Stop BLE routing and publication synchronously before asynchronous disconnect. Add regression coverage and record local and Thor validation.
2026-11-06 19:53:40 +03:00
Delete228 7b7722bd25 Preserve replacement session Deck input routing 2026-11-06 19:25:41 +03:00
Delete228 63004567fd Unify configurable control surfaces and profile management 2026-11-06 19:11:58 +03:00
Delete228 aa3757eff1 Merge remote-tracking branch 'origin/main' into codex/unified-controller-input 2026-11-06 18:50:33 +03:00
William Allenandmaxjivi05 a1bc8467fd feat: show Steam launch options on the Launch tab (#2163)
* Show Steam launch options on the Launch tab

* fix: Launch options is one row on the Launch tab, like the shortcut fields

* fix(launch): reread Steam's launch options when the game page resumes

* fix(launch): keep launch options disabled while they reload on resume

* fix(launch): a saved launch option is not replaced by a read that started before it

* fix(launch): an unreadable Steam config keeps the shown launch options instead of clearing them

* fix(launch): an unparsable Steam config and a pending reload keep the shown launch options

* fix(launch): the resume reload follows the game the panel shows

---------

Co-authored-by: maxjivi05 <[email protected]>
2026-11-06 10:43:44 -05:00
Kurt Himebauch d6566f0dbe feat: GOG achievements on by default, drop stores engine row (#2168) 2026-11-06 10:22:42 -05:00
Delete228 ba5876bf6d Merge latest upstream main before Controller UI integration
# Conflicts:
#	app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
#	app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
#	app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
#	app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt
#	app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt
2026-11-06 14:37:50 +03:00
Delete228 68b0396dfe Merge upstream PC input and dual-screen updates 2026-11-05 00:00:34 +03:00
Delete228 d6b4b0589b Merge upstream shortcuts and dual-screen session updates
Preserve profile bindings, shared held-input ownership and custom panels alongside upstream physical shortcuts, mouse routing and docked menus. Add integration regressions and record the full 2,918-test JVM run.
2026-10-30 17:00:29 +03:00
Delete228 b245017593 Merge upstream main and preserve routed Home input
Integrate upstream 213c2da5, keeping Home/Guide player-slot handling compatible with remapped controls and disabled routing. Preserve the input-settings overlay gate for Home and Steam volume keys.

Validation: 2,707 JVM tests across 389 suites pass. Device validation and latency measurements were not repeated.
2026-10-28 12:45:41 +03:00
Delete228 ed121f247c Fix routed QAM publication and redundant input state writes 2026-10-27 14:23:37 +03:00
Delete228 92b4361100 Merge upstream main into unified input profiles
Integrate screen keys, portrait controls, and Home/Back shortcuts with routed input. Preserve remapped axes, neutral gating, and explicit Back bindings; discard stale queued chord reports when routing changes.

Validation: 2,632 debug JVM tests across 385 suites pass with Java 17 and skipRust. Device testing and latency measurements were not repeated for this merge.
2026-10-27 13:14:29 +03:00
Delete228 5ee3ed07be fix: release panel touch source after tab teardown
Release the panel touch source after child detach callbacks finish publishing button releases. Cover tab switching and whole-panel removal with ownership regressions.

Validation: 22 targeted JVM tests, debug APK build, session-asset check and runtime-input verification passed. Device validation remains pending.
2026-10-24 13:43:09 +03:00
Delete228 884671a065 fix: keep select mapping and panel input ownership consistent
Keep Select-as-Steam in the existing one-time profile migration and remove the obsolete bridge flag. Raw SELECT state stays stable across router attachment changes, while explicit native, disabled and local profile bindings remain authoritative.

Let the panel close its shared DeckControls source. Detached sticks, D-pads and face buttons release only their own controls, preserving other trackpad contacts and clicks.

Add seven regressions. All 2,246 JVM tests pass; the debug APK build, session assets and runtime inputs validate. Thor validation remains pending.
2026-10-24 02:52:09 +03:00
Delete228 1dae4bf8ee fix: preserve input lifecycle and system actions
Keep unused pad rings closed during profile and enable changes. Route Steam menu and VR stick-switch pulses independently of primary bindings, preserving physical holds and cancelling stale releases.

Seed replacement routes with retained source state and require held controls to return to neutral. Add nine regressions and document validation: 2,239 JVM tests and the debug APK build pass; device validation remains pending.
2026-10-24 02:30:11 +03:00
delete d202233beb docs: describe input architecture and Thor validation limits 2026-10-24 01:53:51 +03:00
delete 6530faaf78 fix: preserve shared input ownership and reset profile settings 2026-10-24 01:53:51 +03:00
delete 3dd6aeef46 fix: keep mapped desktop pointer visible and update it each frame 2026-10-24 01:43:34 +03:00
delete 45a496b802 fix: preserve temporary-file modes in input preload open wrappers 2026-10-24 01:43:34 +03:00
delete ab8f41b80e Unify input profiles and configurable controller surfaces 2026-10-24 01:43:34 +03:00
312 changed files with 21785 additions and 7147 deletions

No files matched your search

+17 -3
View File
@@ -39,11 +39,25 @@ jobs:
path: |
gamescope.tzst
gamescope.tzst.sha256
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.components-token.outputs.token }}
GH_REPO: Droid-Deck/DroidDeck-Components
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
exit 1
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" --notes "gamescope 3.16.29 with tools/gamescope/patches ($GITHUB_REPOSITORY $GITHUB_SHA, run $GITHUB_RUN_ID); staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256
gh release edit "$TAG" --latest=false --prerelease=false
+29 -8
View File
@@ -136,11 +136,18 @@ jobs:
if-no-files-found: error
retention-days: 14
components:
name: Prepare verified components
components-runner:
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
uses: ./.github/workflows/pick-runner.yml
secrets:
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
components:
name: Prepare verified components
needs: [source, components-runner]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ fromJSON(needs.components-runner.outputs.labels) }}
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -306,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
@@ -318,11 +330,18 @@ jobs:
compression-level: 0
retention-days: 14
build-runner:
needs: [components]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
uses: ./.github/workflows/pick-runner.yml
secrets:
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
build:
needs: [source, components, xbox]
needs: [source, components, xbox, build-runner]
name: Compile APK and Android tests
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
runs-on: ${{ fromJSON(needs.build-runner.outputs.labels) }}
timeout-minutes: 45
outputs:
variants: ${{ steps.variants.outputs.list }}
@@ -544,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
+60
View File
@@ -0,0 +1,60 @@
name: Pick runner
on:
workflow_call:
outputs:
labels:
value: ${{ jobs.pick.outputs.labels }}
secrets:
RUNNER_STATUS_TOKEN:
required: false
permissions: {}
jobs:
pick:
name: Pick runner
runs-on: ubuntu-24.04
timeout-minutes: 2
outputs:
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
steps:
- name: Pick the compile runner
id: runner
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
run: |
set -euo pipefail
labels='"ubuntu-24.04"'
if [ -n "$GH_TOKEN" ]; then
idle=$(gh api "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" --jq '[.runners[] | select(.status == "online" and .busy == false and any(.labels[]; .name == "droiddeck-ws"))] | length' 2>/dev/null || echo 0)
if [ "${idle:-0}" -gt 0 ]; then
labels='["self-hosted","droiddeck-ws"]'
fi
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"
+10 -2
View File
@@ -47,10 +47,18 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
retry() {
for attempt in 1 2 3 4; do
"$@" && return 0
[ "$attempt" = 4 ] || sleep $((attempt * 20))
done
return 1
}
existing=$(retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name')
found=0
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
if grep -qxF "$tag" <<<"$existing"; then
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
echo "Deleted $tag"
found=1
fi
+57
View File
@@ -0,0 +1,57 @@
name: PR Release Sweep
run-name: sweep test builds of closed PRs
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-release-sweep
cancel-in-progress: false
jobs:
sweep:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete test builds whose PR is no longer open
env:
CI_TOKEN: ${{ steps.ci-token.outputs.token }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
retry() {
for attempt in 1 2 3 4; do
"$@" && return 0
[ "$attempt" = 4 ] || sleep $((attempt * 20))
done
return 1
}
tags=$(GH_TOKEN="$CI_TOKEN" retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name' |
grep -E '^pr-[0-9]+(\.0)?$' || true)
failed=0
for tag in $tags; do
number=${tag#pr-}
number=${number%.0}
state=$(retry gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .state) || { failed=1; continue; }
if [ "$state" != open ]; then
if GH_TOKEN="$CI_TOKEN" retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag; then
echo "Deleted $tag (PR #$number is $state)"
else
failed=1
fi
fi
done
exit "$failed"
+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 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).
@@ -94,6 +95,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- 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`.
- 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.
- Xbox games load [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) modules (LGPL-2.1-or-later), including `wintypes.dll`. Minecraft's GameInput setup was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT).
- The Xbox Cloud Gaming player uses the WebRTC client of [@unknownskl](https://github.com/unknownskl)'s [Greenlight](https://github.com/unknownskl/greenlight) (MIT), and its offer tweaks and sharpening follow [@redphx](https://github.com/redphx)'s [Better xCloud](https://github.com/redphx/better-xcloud) (MIT). See `tools/xcloud-player/NOTICE.md`.
- The itch.io butler pack ships butler's `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, unRAR restriction on RAR code), unmodified. Other archives use Apache Commons Compress and the runtime's bsdtar (libarchive).
+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}"
-8
View File
@@ -195,7 +195,6 @@
</intent-filter>
</activity>
<activity
android:name=".PinRequestActivity"
android:exported="true"
@@ -208,13 +207,6 @@
</intent-filter>
</activity>
<activity
android:name=".input.ControllerEditorActivity"
android:exported="false"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".sync.SyncthingWebActivity"
+9 -5
View File
@@ -2953,15 +2953,19 @@ EXPORT DIR *opendir(const char *name) {
return dir;
}
static bool open_has_mode(int flags) {
return (flags & O_CREAT) != 0 || (flags & O_TMPFILE) == O_TMPFILE;
}
EXPORT int open(const char *pathname, int flags, ...) {
va_list va;
mode_t mode;
mode_t mode = 0;
int fd;
bool hasMode;
va_start(va, flags);
hasMode = flags & O_CREAT;
hasMode = open_has_mode(flags);
if (hasMode) {
mode = va_arg(va, mode_t);
@@ -3033,13 +3037,13 @@ EXPORT int open(const char *pathname, int flags, ...) {
EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
va_list va;
mode_t mode;
mode_t mode = 0;
int fd;
bool hasMode;
va_start(va, flags);
hasMode = flags & O_CREAT;
hasMode = open_has_mode(flags);
if (hasMode) {
mode = va_arg(va, mode_t);
@@ -4251,7 +4255,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
#ifdef __GLIBC__
static mode_t open_mode(int flags, va_list va) {
return (flags & (O_CREAT | O_TMPFILE)) ? va_arg(va, mode_t) : 0;
return open_has_mode(flags) ? va_arg(va, mode_t) : 0;
}
EXPORT int open64(const char *pathname, int flags, ...) {
@@ -5,8 +5,11 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ProtonDefault
import com.droiddeck.launcher.session.SessionPrefs
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
@@ -20,6 +23,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
get() = downloads
private set(value) { downloads = value }
val compDownloadRevision: Long get() = downloadRevision
var compModes by mutableStateOf<Map<String, String>>(emptyMap())
var compAuto by mutableStateOf<Map<String, List<ComponentUpdater.Status>>>(emptyMap())
var compAutoHeld by mutableStateOf<Set<String>>(emptySet())
var compAutoChecking by mutableStateOf(false)
private var shownDefault: String? = null
fun refreshComponents(snapshotFirst: Boolean = false) {
@@ -29,7 +36,14 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
runCatching { ComponentUpdater.settle(activity) }
val modes = ComponentsManager.COMPONENTS.associateWith { SessionPrefs.componentMode(activity, it) }
val auto = ComponentsManager.COMPONENTS.associateWith { runCatching { ComponentUpdater.status(activity, it) }.getOrDefault(emptyList()) }
val held = if (runCatching { ComponentsManager.autoDxvkHeld(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
ui.post {
compModes = modes
compAuto = auto
compAutoHeld = held
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (chosen != null && chosen != shownDefault) {
@@ -42,6 +56,35 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
}, "components").start()
}
fun chooseComponentMode(comp: String, mode: String) {
SessionPrefs.setComponentMode(activity, comp, mode)
compModes = compModes + (comp to mode)
if (mode != ComponentUpdates.AUTO) return
checkComponentUpdates(force = true)
}
private var checkAgain: Boolean? = null
fun checkComponentUpdates(force: Boolean) {
if (compAutoChecking) {
checkAgain = force || checkAgain == true
return
}
compAutoChecking = true
Thread({
val notes = runCatching { ComponentUpdater.ensure(activity, force) }.onFailure { Log.w(TAG, "component updates", it) }.getOrDefault(emptyList())
ui.post {
compAutoChecking = false
checkAgain?.let { again ->
checkAgain = null
checkComponentUpdates(again)
}
if (notes.isNotEmpty()) android.widget.Toast.makeText(activity, notes.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "component-updates").start()
}
fun chooseProton(id: String) {
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
compProton = id
@@ -79,10 +79,12 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
canRestart = !sessionRunning,
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
)
fun tpUbwcModeForDevice(): String? = tpUbwcMode.takeIf { gpu.model == 740 }
fun tpUbwcRestartRequired(): Boolean = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity)
fun chooseTpUbwcMode(mode: String) {
if (gpu.model != 740) return
SessionPrefs.setTpUbwcMode(activity, mode)
@@ -127,8 +129,14 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
)
}
var restartBlocked: () -> Boolean = { SessionState.running }
fun loadTpUbwcMode() {
tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
}
fun restartApp() {
if (SessionState.running) return
if (restartBlocked()) return
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
@@ -595,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
@@ -40,6 +41,8 @@ import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
@@ -61,9 +64,7 @@ import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.ControllerEditorActivity
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
@@ -94,7 +95,11 @@ class FrontEndActivity : ComponentActivity() {
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
private val protons = ProtonMenu(
this, ui,
idle = { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null },
onInstalled = { if (!OfflineMode.enabled(this)) components.checkComponentUpdates(force = false) },
)
private var installed by mutableStateOf<String?>(null)
private var flood by mutableStateOf<PendingFlood?>(null)
@@ -196,11 +201,13 @@ class FrontEndActivity : ComponentActivity() {
private var downloadPrefix by mutableStateOf("")
private var downloadProxy by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var lowStorageWarning by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var navRequest by mutableStateOf<String?>(null)
private val gameLaunchRequest = java.util.concurrent.atomic.AtomicLong()
private var preparingGames by mutableStateOf(emptySet<String>())
private var storesShowMature by mutableStateOf(false)
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
@@ -618,6 +625,7 @@ class FrontEndActivity : ComponentActivity() {
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenSessions by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenControls by mutableStateOf(true)
private var androidTextFieldKeyboard by mutableStateOf(false)
private var launcherStarted = false
private lateinit var displayManager: DisplayManager
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
@@ -866,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)
@@ -881,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 {
@@ -908,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
}
}
@@ -1077,6 +1097,8 @@ class FrontEndActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
drivers.restartBlocked = ::runtimeChangesBlocked
drivers.loadTpUbwcMode()
pendingSessionLogExport = savedInstanceState?.getString(KEY_PENDING_SESSION_LOG_EXPORT)?.let(::File)
homeChoiceOpen = savedInstanceState?.getBoolean(STATE_HOME_CHOICE_OPEN) == true
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
@@ -1103,6 +1125,7 @@ class FrontEndActivity : ComponentActivity() {
downloadProxy = DownloadSource.proxyHostPort(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
lowStorageWarning = SessionPrefs.lowStorageWarning(this)
LauncherIcon.repair(this, intent?.component)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
@@ -1113,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(
@@ -1137,6 +1164,7 @@ class FrontEndActivity : ComponentActivity() {
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
vkBridge = vkBridge, venus = venus, venusHostVulkan = venusHostVulkan, vkBridgeSafe = vkBridgeSafe, bridgeProtonDxvk = bridgeProtonDxvk, adreno = adrenoGpu,
shortcutPicker = shortcutPicker,
preparingGames = preparingGames,
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
@@ -1148,6 +1176,7 @@ class FrontEndActivity : ComponentActivity() {
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
graphicsLogs = graphicsLogs, steamGpuDrawing = steamGpuDrawing, bridgeFellBack = bridgeFellBack, bridgeFellBackMemory = bridgeFellBackMemory,
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
tpUbwcMode = drivers.tpUbwcModeForDevice(), tpUbwcRestartRequired = drivers.tpUbwcRestartRequired(), canRestartApp = !runtimeChangesBlocked(),
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -1167,6 +1196,7 @@ class FrontEndActivity : ComponentActivity() {
dualScreenMode = dualScreenMode,
dualScreenSessions = dualScreenSessions,
dualScreenControls = dualScreenControls,
androidTextFieldKeyboard = androidTextFieldKeyboard,
packages = packageRows,
packageCatalogLoading = catalogLoading,
packageBusyId = pkgId,
@@ -1189,6 +1219,7 @@ class FrontEndActivity : ComponentActivity() {
downloadPrefix = downloadPrefix,
downloadProxy = downloadProxy,
animationsEnabled = animationsEnabled,
lowStorageWarning = lowStorageWarning,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
@@ -1215,14 +1246,26 @@ class FrontEndActivity : ComponentActivity() {
},
onSteamGame = { g ->
if (shortcutPicker) chooseGameShortcut(g)
else {
val request = gameLaunchRequest.incrementAndGet()
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g) {
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) return@beforeLaunch
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
else if (g.launchKey !in preparingGames) {
preparingGames = preparingGames + g.launchKey
prepareAndLaunch(g, g.launchKey)
}
},
onStoreGame = { store, id ->
val key = "${store.id}:$id"
if (key !in preparingGames) {
preparingGames = preparingGames + key
val app = applicationContext
Thread({
val g = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
runOnUiThread {
if (g != null && !isFinishing && !isDestroyed) prepareAndLaunch(g, key)
else {
preparingGames = preparingGames - key
if (g == null && !isFinishing && !isDestroyed) android.widget.Toast.makeText(this, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
}
}
}
}, "store-play").start()
}
},
onEpicLauncher = { openEpicLauncher() },
@@ -1380,6 +1423,8 @@ class FrontEndActivity : ComponentActivity() {
},
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
onRestartApp = { drivers.restartApp() },
onBackgroundAppLimit = { limit ->
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
@@ -1483,6 +1528,14 @@ class FrontEndActivity : ComponentActivity() {
SessionPrefs.setDualScreenSessionControls(this, on)
dualScreenControls = on
},
onAndroidTextFieldKeyboard = { on ->
SessionPrefs.setAndroidTextFieldKeyboard(this, on)
androidTextFieldKeyboard = on
},
onLowStorageWarning = { on ->
SessionPrefs.setLowStorageWarning(this, on)
lowStorageWarning = on
},
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
@@ -1582,23 +1635,7 @@ class FrontEndActivity : ComponentActivity() {
},
updates = updates.actions(),
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
onOpacity = { o -> ControllerPrefs.setOpacity(this, o); refreshController() },
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
onDeadZone = { v -> ControllerPrefs.setDeadZone(this, v); refreshController() },
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
page = page,
@@ -1648,6 +1685,8 @@ class FrontEndActivity : ComponentActivity() {
lowSpace?.let { free ->
com.droiddeck.launcher.ui.LowSpaceDialog(
free,
warn = lowStorageWarning,
onWarnChange = { on -> SessionPrefs.setLowStorageWarning(this, on); lowStorageWarning = on },
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
onDismiss = { lowSpace = null },
@@ -1743,8 +1782,9 @@ class FrontEndActivity : ComponentActivity() {
private fun playSteam() {
val storage = com.droiddeck.launcher.storage.StorageState
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
lowSpace = storage.freeBytes(this)
val free = storage.freeBytes(this)
if (storage.shouldWarnLowSpace(SessionPrefs.lowStorageWarning(this), storage.lowSpaceDismissed, SessionState.running, free)) {
lowSpace = free
storage.refresh(this)
} else startSteamSession()
}
@@ -1880,6 +1920,13 @@ class FrontEndActivity : ComponentActivity() {
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
if (!OfflineMode.enabled(this)) {
val updated = runCatching { ComponentUpdater.ensure(this, force = false) }
.onFailure { android.util.Log.w("ComponentUpdater", "automatic component updates", it) }.getOrDefault(emptyList())
if (updated.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, updated.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
}
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
@@ -1942,6 +1989,18 @@ class FrontEndActivity : ComponentActivity() {
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
else @Suppress("DEPRECATION") state.getParcelable(key)
private fun prepareAndLaunch(g: Library.SteamGame, key: String) {
val request = gameLaunchRequest.incrementAndGet()
val done = { preparingGames = preparingGames - key }
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g, onBlocked = done) {
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) { done(); return@beforeLaunch }
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
done()
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
}
}
}
override fun onDestroy() {
gameLaunchRequest.incrementAndGet()
cancelCompatLaunch()
@@ -2005,6 +2064,7 @@ class FrontEndActivity : ComponentActivity() {
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
dualScreenSessions = SessionPrefs.dualScreenSessionMode(this)
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
androidTextFieldKeyboard = SessionPrefs.androidTextFieldKeyboard(this)
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
dualDisplayId = target?.displayId ?: -1
syncDualPanel()
@@ -2097,13 +2157,7 @@ class FrontEndActivity : ComponentActivity() {
@Composable
private fun MappingHost() {
val settings = controllerSettings ?: return
ControllerMappingPage(
mapping = settings.mapping,
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
onBack = { showMapping = false },
)
com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false })
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
@@ -2127,9 +2181,11 @@ class FrontEndActivity : ComponentActivity() {
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction(getString(R.string.comp_busy_importing)) {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
val tmp = File.createTempFile("component-import", ".wcp", cacheDir)
try {
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
getString(R.string.comp_imported, ComponentUpdater.importPackage(this, tmp, name).version)
} finally { tmp.delete() }
}
}
@@ -2153,7 +2209,15 @@ class FrontEndActivity : ComponentActivity() {
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onRefresh = {
components.refreshComponentCatalog()
if (components.compModes.values.any { it == ComponentUpdates.AUTO }) components.checkComponentUpdates(force = true)
},
compModes = components.compModes,
compAuto = components.compAuto,
compAutoHeld = components.compAutoHeld,
compAutoChecking = components.compAutoChecking,
onCompMode = { comp, mode -> components.chooseComponentMode(comp, mode) },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
@@ -2177,7 +2241,6 @@ class FrontEndActivity : ComponentActivity() {
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onMode = { m -> drivers.chooseMode(m) },
onRestart = { drivers.restartApp() },
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
@@ -2230,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)
@@ -2248,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
@@ -2389,6 +2455,7 @@ class FrontEndActivity : ComponentActivity() {
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
},
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onInputSettings = { showPerformance = false; showComponents = false; showMapping = true },
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
@@ -2982,6 +3049,7 @@ class FrontEndActivity : ComponentActivity() {
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
ready = false
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { removal.run(it) }
@@ -3008,6 +3076,7 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
if (ok == true && LinuxRuntime.isInstalled(this)) protons.checkUpdates()
}
}, "runtime-operation").start()
}
@@ -3015,9 +3084,12 @@ class FrontEndActivity : ComponentActivity() {
private fun autoUpdateRuntime() {
val release = available?.takeIf { catalogFresh } ?: return
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
if (release.version == installed || installed == null) return
if (installed != null) com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
if (release.version == installed) return
if (installed == null && (OfflineMode.enabled(this) || !com.droiddeck.launcher.core.DeviceSupport.canDraw(this) ||
!com.droiddeck.launcher.frontend.AutoComponents.online(this) || com.droiddeck.launcher.frontend.AutoComponents.unfavourable(this))) return
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
Log.i(TAG, "runtime: installing ${release.version} over ${installed ?: "nothing"} by itself")
install(release)
}
}
@@ -18,6 +18,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
internal class ProtonMenu(
private val activity: android.app.Activity,
private val ui: Handler,
private val onInstalled: () -> Unit = {},
private val idle: () -> Boolean = { true },
) {
private object Operation {
@@ -61,6 +62,7 @@ internal class ProtonMenu(
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
if (problem == null) onInstalled()
if (automatic) ensureAutoUpdates()
}
}, "install-proton-$id").start()
File diff suppressed because it is too large. Load diff
@@ -26,6 +26,10 @@ object AgentGuest {
private set
var keyboardRequests by mutableIntStateOf(0)
private set
var gameTextField by mutableStateOf(false)
private set
var gameProgram by mutableStateOf<String?>(null)
private set
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@@ -43,7 +47,7 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false; desktopTextField = false }
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -59,6 +63,7 @@ object AgentGuest {
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
"wine-ime.json" -> readJson(File(dir, path))?.let { recordGameTextField(this, it) }
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
}
@@ -71,7 +76,7 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false; desktopTextField = false }
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
}
}
@@ -129,6 +134,16 @@ object AgentGuest {
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
}
private fun recordGameTextField(source: FileObserver, snapshot: JSONObject) {
val focused = snapshot.optBoolean("textField")
val program = snapshot.optString("program").takeIf { it.isNotEmpty() }
synchronized(lock) {
if (source !== observer) return
main.post { gameProgram = program; gameTextField = focused }
}
SessionEvents.record("game.ime.changed", mapOf("textField" to focused, "program" to program, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -58,7 +58,7 @@ public final class Downloader {
private static void closeAsync(InputStream active) {
CANCEL_EXECUTOR.execute(() -> {
try { active.close(); } catch (IOException ignored) { }
try { active.close(); } catch (IOException | IllegalStateException ignored) { }
});
}
@@ -12,6 +12,9 @@ object Hashes {
@JvmStatic
fun sha512(file: File): String = hex(file, "SHA-512")
@JvmStatic
fun md5(file: File): String = hex(file, "MD5")
@JvmStatic
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
@@ -90,7 +90,8 @@ object StorageRoots {
.filter { present(it.state) }
.forEachIndexed { index, volume ->
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
val directory = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) volume.directory else null
val dirName = directory?.name?.takeIf { it.isNotBlank() }
val descSlug = volume.getDescription(context)?.trim()
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
@@ -98,9 +99,7 @@ object StorageRoots {
entry.removable = !volume.isPrimary && volume.isRemovable
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
volume.directory?.let { entry.candidates += it }
}
directory?.let { entry.candidates += it }
val name = uuid ?: dirName
if (name != null) {
entry.candidates += File("/storage/$name")
@@ -475,15 +475,15 @@ object AutoComponents {
return true
}
fun beforeLaunch(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
fun beforeLaunch(context: Context, game: Library.SteamGame, onBlocked: () -> Unit = {}, onReady: () -> Unit) {
val app = context.applicationContext
Thread({
val ready = runCatching { prepareLaunch(app, game) }.getOrElse {
Log.w(TAG, "${game.name}: launch repair discovery failed: ${it.javaClass.simpleName}: ${it.message}")
false
}
Handler(Looper.getMainLooper()).post(if (ready) onReady else onBlocked)
queueGame(app, game, asked = false)
if (ready) Handler(Looper.getMainLooper()).post(onReady)
}, "auto-components-launch").apply { isDaemon = true; start() }
}
@@ -18,11 +18,13 @@ 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
val profileKey: String get() = when {
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
@@ -30,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"
@@ -220,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()
@@ -248,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")
@@ -14,8 +14,8 @@ object SteamLaunchOptions {
private const val TAG = "SteamLaunchOptions"
private val STEAM = listOf("UserLocalConfigStore", "Software", "Valve", "Steam")
fun current(context: Context, appId: Int): String =
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId).orEmpty()
fun current(context: Context, appId: Int): String? =
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId)
enum class Saved { YES, NO, DESKTOP }
@@ -35,7 +35,8 @@ object SteamLaunchOptions {
internal fun steam(steamHome: File, appId: Int): String? {
val config = AddedGames.accountConfig(steamHome) ?: return null
val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null
return launchOptions(text, appId)
if (section(text, STEAM) == null) return null
return launchOptions(text, appId).orEmpty()
}
internal fun write(steamHome: File, appId: Int, options: String): Boolean {
@@ -27,12 +27,11 @@ object AutoPicks {
}
val BUILT_IN = listOf(
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A8XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_ONEUI)),
Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
Rule(setOf(Family.A6XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
)
@@ -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)
@@ -97,6 +97,7 @@ object TurnipReleases {
// Venus): its driver pairs fall past the first 15, so it is read in one full page.
val perPage = if (src.repo == NON_ADRENO_REPO) 100 else 15
val releases = JSONArray(get(context, "https://api.github.com/repos/${src.repo}/releases?per_page=$perPage"))
val found = JSONArray()
val seen = HashSet<String>()
var newest: String? = null
for (i in 0 until releases.length()) {
@@ -114,12 +115,13 @@ object TurnipReleases {
val label = kind.label
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
found.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
.put("bundle", kind.bundle).put("id", a.optLong("id")).put("updated_at", a.optString("updated_at")))
}
}
for (i in 0 until found.length()) assets.put(found.get(i))
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
} catch (e: Exception) {
failed.add(src.label)
@@ -127,7 +129,8 @@ object TurnipReleases {
}
}
if (failed.size == sources.size) throw lastError ?: IOException("no source answered")
runCatching { get(context, AutoPicks.URL, AutoPicks.URL) }
carryOver(failed, prefs(context).getString(KEY_RELEASE, null)?.let { runCatching { JSONObject(it) }.getOrNull() }, assets, latest)
runCatching { fetch(context, AutoPicks.URL, AutoPicks.URL) }
.onFailure { Log.i(TAG, "auto picks: ${it.message}") }
.getOrNull()?.takeIf { AutoPicks.parse(it) != null }
?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() }
@@ -137,12 +140,22 @@ object TurnipReleases {
return parse(stored)
}
internal var fetch: (Context, String, String) -> String = { context, source, url -> get(context, source, url) }
internal fun carryOver(failed: List<String>, previous: JSONObject?, assets: JSONArray, latest: JSONArray) {
if (previous == null || failed.isEmpty()) return
for ((key, into) in listOf("assets" to assets, "latest" to latest)) {
val old = previous.optJSONArray(key) ?: continue
for (i in 0 until old.length()) old.optJSONObject(i)?.takeIf { it.optString("source") in failed }?.let(into::put)
}
}
private fun get(context: Context, url: String): String {
val sources = DownloadSource.candidates(url)
var last: IOException? = null
for ((index, source) in sources.withIndex()) {
try {
val body = get(context, source, url)
val body = fetch(context, source, url)
DownloadSource.succeeded(source)
return body
} catch (e: IOException) {
@@ -141,6 +141,8 @@ class DroidDeckHudView(context: Context) : View(context) {
private var dDividerTop = -1f
private var dFrame: Bitmap? = null
private var dFrameKey = ""
private var sFrame: Bitmap? = null
private var sFrameKey = ""
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val colGpu = 0xFF5EE08A.toInt()
@@ -274,9 +276,17 @@ class DroidDeckHudView(context: Context) : View(context) {
seenFpsUpdates = updates
periodDone.trySend(Unit)
}
if (graphRect != null) invalidate()
if (graphRect == null) return
if (stripRect != null) {
val now = SystemClock.uptimeMillis()
if (now - lastGraphDrawMs < STRIP_GRAPH_REDRAW_MS) return
lastGraphDrawMs = now
}
invalidate()
}
private var lastGraphDrawMs = 0L
private fun cycleSize() {
size = size.next()
onSizeCycledListener?.accept(size.token)
@@ -1340,10 +1350,15 @@ class DroidDeckHudView(context: Context) : View(context) {
canvas.drawText(g.text, g.x, g.baseline, paint)
if (g.glow) paint.clearShadowLayer()
}
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
headerMark?.let {
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedMark(canvas, it)
else DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent)
}
headerWordmark?.let { WordmarkArt.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
graphRect?.let { drawGraph(canvas, it) }
graphRect?.let {
if (stripRect != null && layerType != LAYER_TYPE_SOFTWARE) drawCachedGraph(canvas, it) else drawGraph(canvas, it)
}
lockController.drawBadge(canvas)
}
@@ -1368,18 +1383,66 @@ class DroidDeckHudView(context: Context) : View(context) {
canvas.drawPath(graphPath, graphPaint)
}
private var cachedGraphBitmap: Bitmap? = null
private var cachedGraphCanvas: Canvas? = null
private val cachedGraphRect = RectF()
private var cachedMarkBitmap: Bitmap? = null
private var cachedMarkKey = ""
private fun drawCachedGraph(canvas: Canvas, rect: RectF) {
val w = rect.width().roundToInt().coerceAtLeast(1)
val h = rect.height().roundToInt().coerceAtLeast(1)
var bitmap = cachedGraphBitmap
if (bitmap == null || bitmap.width != w || bitmap.height != h) {
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
cachedGraphBitmap = bitmap
cachedGraphCanvas = Canvas(bitmap)
cachedGraphRect.set(0f, 0f, w.toFloat(), h.toFloat())
}
bitmap.eraseColor(Color.TRANSPARENT)
drawGraph(cachedGraphCanvas!!, cachedGraphRect)
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
}
private fun drawCachedMark(canvas: Canvas, rect: RectF) {
val w = rect.width().roundToInt().coerceAtLeast(1)
val h = rect.height().roundToInt().coerceAtLeast(1)
val key = "$w,$h,${blend(colValue)},$accent"
var bitmap = cachedMarkBitmap
if (bitmap == null || key != cachedMarkKey) {
bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
DroidDeckMark.draw(Canvas(bitmap), 0f, 0f, h.toFloat(), blend(colValue), accent)
cachedMarkBitmap = bitmap
cachedMarkKey = key
}
canvas.drawBitmap(bitmap, rect.left, rect.top, null)
}
private fun drawStrip(canvas: Canvas, rect: RectF) {
headerPaint.shader = null
headerPaint.color = panelColor()
canvas.drawRect(rect, headerPaint)
val stroke = outlineWidth()
if (stroke <= 0f) return
val line = max(stroke / 2f, density)
val p = parent as? View
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
if (atBottom) canvas.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
else canvas.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
stripFrame(rect, atBottom)?.let { canvas.drawBitmap(it, 0f, 0f, null) }
}
private fun stripFrame(rect: RectF, atBottom: Boolean): Bitmap? {
val w = rect.width().roundToInt(); val h = rect.height().roundToInt()
if (w <= 0 || h <= 0) return null
val stroke = outlineWidth()
val key = "$w,$h,$stroke,$atBottom,$surfaceRgb,$bgOpacity,$outlineIntensity,${outlineColor()}"
if (key == sFrameKey) sFrame?.let { return it }
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
headerPaint.shader = null
headerPaint.color = panelColor()
c.drawRect(rect, headerPaint)
if (stroke > 0f) {
val line = max(stroke / 2f, density)
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
if (atBottom) c.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
else c.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
}
sFrame = bmp; sFrameKey = key
return bmp
}
private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f
@@ -1499,6 +1562,7 @@ class DroidDeckHudView(context: Context) : View(context) {
const val TAB_PAD_X_SP = 7f
const val TAB_PAD_Y_SP = 5.5f
const val REFRESH_MS = 500L
const val STRIP_GRAPH_REDRAW_MS = 66L
private const val HUE_IDLE_MS = 500L
const val MIN_SCALE = 0.5f
const val MAX_SCALE = 2f
@@ -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
@@ -1,136 +0,0 @@
package com.droiddeck.launcher.input
import com.droiddeck.launcher.ui.focusGlide
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.onSignal
import com.droiddeck.launcher.ui.LocalPalette
class ControllerEditorActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private lateinit var controls: OnScreenControls
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
immersive()
val root = FrameLayout(this)
root.setBackgroundColor(0xFF0B0F14.toInt())
controls = OnScreenControls(this, null, editing = true)
root.addView(controls, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
val bar = ComposeView(this).apply {
setContent {
DroidDeckTheme {
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
) {
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
snap = !snap
ControllerPrefs.setSnap(context, snap)
controls.setSnap(snap)
}
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
controls.saveLayout()
finish()
}
}
}
}
}
}
root.addView(bar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
setContentView(root)
}
@Composable
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
val pal = LocalPalette.current
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = when {
primary -> pal.onSignal
selected == true -> pal.signal
else -> MaterialTheme.colorScheme.onBackground
},
modifier = Modifier.clip(RoundedCornerShape(50))
.background(
when {
primary -> pal.signal
selected == true -> pal.signal.copy(alpha = 0.18f)
else -> Color.White.copy(alpha = 0.06f)
},
)
.focusGlide(RoundedCornerShape(50))
.then(
if (selected == null) Modifier.clickable(onClick = onClick)
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) immersive()
}
private fun immersive() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
}
@@ -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()
}
@@ -169,62 +162,7 @@ object ControllerPrefs {
prefs(context).edit().remove("layout.${width}x$height").apply()
}
fun screenKeysShown(context: Context): Boolean = prefs(context).getBoolean("screenKeysShown", true)
fun setScreenKeysShown(context: Context, shown: Boolean) {
prefs(context).edit().putBoolean("screenKeysShown", shown).apply()
}
fun screenKeys(context: Context): List<ScreenKey> = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty())
fun setScreenKeys(context: Context, keys: List<ScreenKey>) {
prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply()
}
fun encodeScreenKeys(keys: List<ScreenKey>): String = keys.joinToString(";") {
"${it.codes.joinToString("+")}:${it.x},${it.y},${it.size}:${escape(it.glyph)}:${escape(it.text)}:${escape(it.image)}:${it.opacity}:${it.shape}:${if (it.toggle) 1 else 0}:${if (it.labels) 1 else 0}"
}
fun decodeScreenKeys(raw: String): List<ScreenKey> = raw.split(';').mapNotNull { entry ->
val parts = entry.split(':')
if (parts.size < 2) return@mapNotNull null
val keys = parts[0].split('+').mapNotNull { it.toIntOrNull() }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val nums = parts[1].split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 3 } ?: return@mapNotNull null
ScreenKey(
keys, nums[0], nums[1], nums[2].toInt(),
parts.getOrNull(2)?.let(::unescape).orEmpty(),
parts.getOrNull(3)?.let(::unescape).orEmpty(),
parts.getOrNull(4)?.let(::unescape).orEmpty(),
parts.getOrNull(5)?.toFloatOrNull()?.coerceIn(0.2f, 1f) ?: 1f,
parts.getOrNull(6)?.toIntOrNull()?.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) ?: SCREEN_KEY_PILL,
parts.getOrNull(7) == "1",
parts.getOrNull(8) != "0",
)
}
fun screenLayouts(context: Context): Map<String, String> =
prefs(context).all.mapNotNull { (key, value) ->
if (!key.startsWith("screenLayout.") || value !is String) null else key.removePrefix("screenLayout.") to value
}.toMap()
fun saveScreenLayout(context: Context, name: String, raw: String) {
prefs(context).edit().putString("screenLayout.$name", raw).apply()
}
fun deleteScreenLayout(context: Context, name: String) {
prefs(context).edit().remove("screenLayout.$name").apply()
}
private fun escape(value: String) = value.replace("%", "%25").replace(";", "%3B").replace(":", "%3A").replace(",", "%2C")
private fun unescape(value: String) = value.replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
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()
}
}
@@ -5,53 +5,165 @@ object DeckControls {
const val R4 = 2
const val L5 = 4
const val R5 = 8
private const val LEFT_TOUCH = 16
private const val RIGHT_TOUCH = 32
private const val LEFT_CLICK = 64
private const val RIGHT_CLICK = 128
private const val SLOT = 0
private var controls = 0
private val controls = arrayOf(InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5,
InputControl.LEFT_PAD, InputControl.RIGHT_PAD, InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK)
private val sources = ArrayList<Source>()
private val physical = Source(InputRole.PHYSICAL)
private var router: InputRouter? = null
private val pads = ShortArray(4)
private val pressure = ShortArray(2)
private val touchOwners = arrayOfNulls<Source>(2)
@Synchronized
fun setGrip(bit: Int, down: Boolean) {
set(bit, down)
fun setRouter(next: InputRouter?) {
if (router === next) return
for (source in sources) source.closeRoutes()
router = next
for (source in sources) source.configure()
publish()
}
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
val at = if (right) 2 else 0
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
pads[at] = if (touching) axis(x) else 0
pads[at + 1] = if (touching) axis(y) else 0
publish()
fun clearRouter(owner: InputRouter?) {
if (owner != null && router === owner) setRouter(null)
}
@Synchronized
fun setClick(right: Boolean, down: Boolean) {
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
publish()
}
@JvmOverloads
fun source(role: InputRole, routed: Boolean = false) = Source(role, routed)
@Synchronized
fun setGrip(bit: Int, down: Boolean) = physical.setGrip(bit, down)
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = physical.setPad(right, touching, x, y)
@Synchronized
fun setClick(right: Boolean, down: Boolean) = physical.setClick(right, down)
@Synchronized
fun releaseAll() {
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
controls = 0
pads.fill(0)
pressure.fill(0)
for (source in sources) source.clear()
publish()
}
private fun set(bit: Int, on: Boolean) {
controls = if (on) controls or bit else controls and bit.inv()
@Synchronized
fun suspend() {
for (source in sources) source.suspend()
publish()
}
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
@Synchronized
fun refresh() {
for (source in sources) source.route()
publish()
}
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
class Source internal constructor(private val role: InputRole, private val routed: Boolean = false) {
private val x = FloatArray(8)
private val y = FloatArray(8)
private val touched = BooleanArray(8)
private var routes: Array<InputRouter.Channel>? = null
internal var mask = 0
internal val axes = ShortArray(4)
internal fun configure() {
routes = router?.takeUnless { routed }?.let { r -> Array(8) { i ->
r.channel(role, controls[i]).also { it.awaitNeutral(x[i], y[i], touched[i]) }
} }
mask = 0
axes.fill(0)
}
private fun attach() {
if (this !in sources) { sources.add(this); configure() }
}
fun setGrip(bit: Int, down: Boolean) = synchronized(DeckControls) {
val at = when (bit) { L4 -> 0; R4 -> 1; L5 -> 2; R5 -> 3; else -> return@synchronized }
update(at, if (down) 1f else 0f)
}
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = synchronized(DeckControls) {
update(if (right) 5 else 4, if (touching) x else 0f, if (touching) y else 0f, touching)
}
fun setClick(right: Boolean, down: Boolean) = synchronized(DeckControls) {
update(if (right) 7 else 6, if (down) 1f else 0f)
}
private fun update(at: Int, value: Float, second: Float = 0f, contact: Boolean = false) {
val timing = InputTiming.begin()
attach()
x[at] = value; y[at] = second; touched[at] = contact
route()
publish()
InputTiming.end(timing)
}
internal fun native(control: InputControl?, value: Float, second: Float, contact: Boolean) = synchronized(DeckControls) {
require(routed)
attach()
x.fill(0f); y.fill(0f); touched.fill(false)
val at = controls.indexOf(control)
if (at >= 0) { x[at] = value; y[at] = second; touched[at] = contact }
route(); publish()
}
internal fun route() {
mask = 0
axes.fill(0)
for (i in controls.indices) {
val target = if (routes == null) controls[i] else routes!![i].update(x[i], y[i], touched[i]) ?: continue
val at = controls.indexOf(target)
if (at < 0) continue
if (at == 4 || at == 5) {
if (touched[i]) {
mask = mask or (1 shl at)
axes[(at - 4) * 2] = axis(x[i]); axes[(at - 4) * 2 + 1] = axis(y[i])
}
} else if (x[i] > 0.5f) mask = mask or (1 shl at)
}
}
internal fun clear() {
x.fill(0f); y.fill(0f); touched.fill(false); mask = 0; axes.fill(0)
routes?.forEach { it.release() }
}
internal fun suspend() {
mask = 0; axes.fill(0)
routes?.forEach { it.suspend() }
}
fun release() = synchronized(DeckControls) { clear(); publish() }
internal fun closeRoutes() { routes?.forEach { it.close() }; routes = null }
fun close() = synchronized(DeckControls) {
clear(); closeRoutes(); sources.remove(this); publish()
}
}
private fun axis(value: Float) = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() {
var mask = 0
pads.fill(0)
for (source in sources) {
mask = mask or source.mask
}
for (side in 0..1) {
val contact = 16 shl side
val owner = touchOwners[side]?.takeIf { it.mask and contact != 0 }
?: sources.firstOrNull { it.mask and contact != 0 }
touchOwners[side] = owner
if (owner == null) continue
pads[side * 2] = owner.axes[side * 2]
pads[side * 2 + 1] = owner.axes[side * 2 + 1]
}
pressure[0] = if (mask and 64 != 0) Short.MAX_VALUE else 0
pressure[1] = if (mask and 128 != 0) Short.MAX_VALUE else 0
FakeInputWriter.writeDeckControls(0, mask, pads, pressure)
}
}
@@ -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) {
@@ -16,6 +16,7 @@ object DualScreen {
const val MODE_OFF = "off"
val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF)
private const val TYPE_INTERNAL = 1
private const val TYPE_EXTERNAL = 2
private val knownModels = setOf("retroid pocket duo", "ayn thor")
private val touchedDisplays = mutableSetOf<Int>()
private val takenListeners = java.util.concurrent.CopyOnWriteArraySet<() -> Unit>()
@@ -85,9 +86,22 @@ object DualScreen {
when (mode) {
MODE_OFF -> null
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
else -> builtInDisplay(displayManager, primaryDisplayId)
else -> if (dockedToDisplay(displayManager, primaryDisplayId)) null else builtInDisplay(displayManager, primaryDisplayId)
}?.takeUnless { taken(it.displayId) }
fun leavesBottomOnDock(wasDocked: Boolean, docked: Boolean, mode: String, automaticOnBuiltIn: Boolean): Boolean =
docked && !wasDocked && mode == MODE_AUTO && automaticOnBuiltIn
fun dockedToDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Boolean {
val glasses = com.droiddeck.launcher.glasses.Glasses.state.value
val external = displayManager.getDisplays().filter {
it.isValid && it.displayId != primaryDisplayId && it.state != Display.STATE_OFF && displayType(it) == TYPE_EXTERNAL
}
val glassesId = glasses.displayId.takeIf { it != Display.INVALID_DISPLAY }
?: com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(external, glasses.present)?.displayId
return external.any { it.displayId != glassesId && com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(listOf(it), false) == null }
}
fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? =
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0
@@ -1,4 +1,4 @@
package com.droiddeck.launcher.ui
package com.droiddeck.launcher.input
import kotlin.math.min
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.input
import android.view.MotionEvent
internal class GestureTouchStreamGate {
enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS }
enum class Source { SURFACE, ON_SCREEN_CONTROLS }
private var owner: Source? = null
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.input
class HeldInput(private val emit: (Int, Boolean) -> Unit) {
private val owners = IntArray(768)
@Synchronized
fun set(source: Int, code: Int, down: Boolean) {
if (code !in owners.indices) return
val before = owners[code]
val after = if (down) before or source else before and source.inv()
owners[code] = after
if ((before == 0) != (after == 0)) emit(code, after != 0)
}
@Synchronized
fun release(source: Int) {
for (code in owners.indices) if (owners[code] and source != 0) set(source, code, false)
}
@Synchronized
fun clear() {
for (code in owners.indices) if (owners[code] != 0) {
owners[code] = 0
emit(code, false)
}
}
}
@@ -1,16 +1,49 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.Drawable
import com.droiddeck.launcher.R
object ScreenKeyIcons {
fun has(code: Int): Boolean = code in SET
object InputButtonIcons {
// Persisted symbol IDs must not depend on Android resource IDs or overlap Linux key codes.
const val MENU = 1001
const val KEYBOARD = 1002
const val STEAM = 1003
const val QUICK_ACCESS = 1004
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
fun has(code: Int): Boolean = code in codes
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
if (!has(code)) return false
private val keyCodes = listOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
val codes = listOf(MENU, KEYBOARD, STEAM, QUICK_ACCESS) + keyCodes
fun label(context: Context, code: Int): String = when (code) {
MENU -> context.getString(R.string.touch_droiddeck_menu)
KEYBOARD -> context.getString(R.string.drawer_keyboard)
STEAM -> context.getString(R.string.drawer_steam_menu)
QUICK_ACCESS -> context.getString(R.string.drawer_open_qam)
else -> pcKeyLabel(context, code)
}
fun drawable(context: Context, code: Int): Drawable? = when (code) {
MENU -> R.drawable.ic_stat_session
KEYBOARD -> R.drawable.ic_bottom_keyboard
STEAM -> R.drawable.ic_input_steam
QUICK_ACCESS -> R.drawable.ic_input_quick_access
else -> null
}?.let { context.getDrawable(it)?.mutate() }
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint, drawable: Drawable? = null): Boolean {
if (drawable != null) {
drawable.setTint(paint.color)
val half = box / 2f
drawable.setBounds((cx - half).toInt(), (cy - half).toInt(), (cx + half).toInt(), (cy + half).toInt())
drawable.draw(canvas)
return true
}
if (code !in keyCodes) return false
val h = box / 2f
val stroke = box * 0.085f
val path = Path()
@@ -0,0 +1,43 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import java.io.File
import java.util.UUID
internal object InputButtonImages {
const val DIR = "screenkeys"
private const val MAX_SIZE_DP = 140
fun file(context: Context, name: String): File? =
name.takeIf { it.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && it != "." && it != ".." }?.let { File(File(context.filesDir, DIR), it) }
fun import(context: Context, uri: Uri): String? {
val dir = File(context.filesDir, DIR).apply { mkdirs() }
val name = "key_" + UUID.randomUUID() + ".png"
val longest = (MAX_SIZE_DP * context.resources.displayMetrics.density).toInt()
return runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
val source = maxOf(bounds.outWidth, bounds.outHeight)
if (source <= 0) return@runCatching null
var sample = 1
while (source / (sample * 2) >= longest) sample *= 2
val bitmap = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
} ?: return@runCatching null
val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height)
val scaled = if (scale < 1f) Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt().coerceAtLeast(1),
(bitmap.height * scale).toInt().coerceAtLeast(1), true) else bitmap
try {
val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }
if (saved) name else null
} finally {
if (scaled !== bitmap) bitmap.recycle()
scaled.recycle()
}
}.getOrNull().also { if (it == null) File(dir, name).delete() }
}
}
@@ -0,0 +1,123 @@
package com.droiddeck.launcher.input
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.roundToInt
internal object InputLayoutFile {
private val NAMES: Map<Int, String> = mapOf(
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
21 to "KEY_Y", 44 to "KEY_Z",
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
)
private val CODES: Map<String, Int> = NAMES.entries.associate { (code, name) -> name to code }
fun fileName(layout: SavedInputLayout) = layout.name.replace(Regex("[^A-Za-z0-9 _.-]"), "_").trim().ifEmpty { "droiddeck-layout" } + ".icp"
fun export(layout: SavedInputLayout, bindings: SavedInputBindings?, width: Int, height: Int): String? {
val snap = width.coerceAtLeast(1) / 100f
val elements = JSONArray()
for (e in layout.elements) {
val action = bindings?.action(e) ?: InputAction.Disabled
val directional = e.kind == InputElementKind.DPAD || e.kind == InputElementKind.STICK
val codes = when {
directional && action is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left)
!directional && action is InputAction.Keys -> action.codes
else -> continue
}
val binds = JSONArray()
for (code in codes) {
val name = NAMES[code]
if (name != null) binds.put(name) else if (directional) binds.put("")
}
if ((0 until binds.length()).all { binds.getString(it).isEmpty() }) continue
val shape = when {
directional -> "ROUND_RECT"
e.buttonShape == InputButtonShape.CIRCLE -> "CIRCLE"
e.buttonShape == InputButtonShape.RECTANGLE -> "RECT"
else -> "ROUND_RECT"
}
val sizePx = e.height * height
val scale = when {
directional -> sizePx * 2.3f / (14f * snap)
shape == "CIRCLE" -> sizePx / (6f * snap)
else -> sizePx / (4f * snap)
}
elements.put(JSONObject()
.put("type", when (e.kind) { InputElementKind.DPAD -> "D_PAD"; InputElementKind.STICK -> "STICK"; else -> "BUTTON" })
.put("shape", shape).put("customColor", 0).put("bindings", binds).put("scale", scale.toDouble())
.put("x", (e.x + e.width / 2).toDouble()).put("y", (e.y + e.height / 2).toDouble())
.put("toggleSwitch", e.toggle).put("swipeable", false)
.put("text", if (e.icon != 0 || e.image.isNotEmpty()) "" else e.name).put("iconId", 0)
.apply { if (e.opacity < 1f) put("opacity", e.opacity.toDouble()) })
}
if (elements.length() == 0) return null
return JSONObject().put("id", 0).put("name", layout.name).put("cursorSpeed", 1.5).put("elements", elements).toString()
}
fun parse(raw: String, width: Int, density: Float): List<LegacyScreenKey>? {
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val elements = root.optJSONArray("elements") ?: return null
val snap = width.coerceAtLeast(1) / 100f
val keys = mutableListOf<LegacyScreenKey>()
for (i in 0 until elements.length()) {
val element = elements.optJSONObject(i) ?: continue
val type = element.optString("type", "BUTTON")
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
val binds = element.optJSONArray("bindings") ?: continue
val raw = (0 until binds.length()).map { CODES[binds.optString(it)] ?: 0 }
val directional = type != "BUTTON"
val codes = when {
!directional -> raw.filter { it != 0 }.distinct().take(8)
type == "STICK" && raw.size <= 4 -> listOf(raw.getOrElse(0) { 0 }, 0, raw.getOrElse(1) { 0 }, 0, raw.getOrElse(2) { 0 }, 0, raw.getOrElse(3) { 0 }, 0)
else -> raw.take(if (type == "STICK") 8 else 4)
}
if (codes.none { it != 0 }) continue
val shape = when {
type == "D_PAD" -> 5
type == "STICK" -> 6
else -> when (element.optString("shape")) { "CIRCLE" -> 2; "RECT" -> 3; "SQUARE" -> 1; else -> 0 }
}
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it.isFinite() && it > 0f } ?: 1f
val sizePx = when {
directional -> 14f * snap * scale / 2.3f
shape == 2 -> 6f * snap * scale
else -> 4f * snap * scale
}
fun coordinate(name: String) = element.optDouble(name, .5).toFloat().takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: .5f
keys += LegacyScreenKey(codes, coordinate("x"), coordinate("y"), (sizePx / density).roundToInt().coerceIn(24, 140).toFloat(),
text = element.optString("text").trim().take(24),
opacity = element.optDouble("opacity", 1.0).toFloat().takeIf { it.isFinite() }?.coerceIn(.2f, 1f) ?: 1f,
shape = shape, toggle = element.optBoolean("toggleSwitch"))
if (keys.size == 64) break
}
return keys
}
fun import(context: Context, raw: String, fallbackName: String, screen: InputLayoutScreen, style: InputSurfaceStyle,
width: Int, height: Int, density: Float, id: String = newInputId()): Pair<SavedInputLayout, SavedInputBindings>? {
val keys = parse(raw, width, density)?.takeIf { it.isNotEmpty() } ?: return null
val converted = runCatching { LegacyScreenKeyMigration.convert(context, keys, width, height, density) }.getOrNull() ?: return null
val name = runCatching { JSONObject(raw).optString("name") }.getOrNull().orEmpty().trim().take(80).ifEmpty { fallbackName.trim().take(80) }
val layout = SavedInputLayout(id, name, converted.map { it.first }, style = style, screen = screen,
aspect = (width.coerceAtLeast(1).toFloat() / height.coerceAtLeast(1)).coerceIn(.05f, 20f))
return layout to SavedInputBindings(newInputId(), name, false, id, elements = converted.associate { it.first.id to it.second })
}
}
@@ -0,0 +1,395 @@
package com.droiddeck.launcher.input
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 = 14
private const val KEY = "library"
private const val RECOVERY = "library-recovery."
const val OVERLAY = "builtin.overlay"
const val DECK = "builtin.deck"
const val TRACKPAD = "builtin.trackpad"
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
@Synchronized
fun read(context: Context): InputLibrary {
val p = prefs(context)
val raw = p.getString(KEY, null)
val decoded = raw?.let(::decode)
if (raw != null && decoded == null) return scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
var library = decoded ?: scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, migrate(context))))
val version = raw?.let { JSONObject(it).getInt("version") } ?: 0
if (version < 5) library = scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, library)))
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) {
val migrated = runCatching { LegacyScreenKeyMigration.migrate(context, library, legacy) }.getOrElse {
android.util.Log.e("InputLibraries", "Could not migrate screen keys; keeping the original settings", it)
return library
}
val merged = mergeScreenKeys(migrated, legacy)
val upgraded = merged ?: migrated
val encoded = encode(upgraded)
require(encoded.length <= 2_000_000)
val edit = p.edit().putString(KEY, encoded).putString(LegacyScreenKeyMigration.BACKUP, legacy)
if (merged != null) edit.putBoolean(LegacyScreenKeyMigration.MERGED, true)
if (!edit.commit()) return library
LegacyScreenKeyMigration.clean(context)
return upgraded
}
}
val backup = p.getString(LegacyScreenKeyMigration.BACKUP, null)
if (backup != null && !p.contains(LegacyScreenKeyMigration.MERGED)) {
val merged = mergeScreenKeys(library, backup)
if (merged != null) {
val encoded = encode(merged)
if (encoded.length <= 2_000_000 && p.edit().putString(KEY, encoded).putBoolean(LegacyScreenKeyMigration.MERGED, true).commit()) library = merged
}
}
if (raw == null || version < VERSION) write(context, library)
if (p.contains(LegacyScreenKeyMigration.BACKUP)) LegacyScreenKeyMigration.clean(context)
return library
}
private fun mergeScreenKeys(library: InputLibrary, legacy: String): InputLibrary? = runCatching { LegacyScreenKeyMigration.merge(library, legacy) }
.onFailure { android.util.Log.e("InputLibraries", "Could not show the imported screen keys with the gamepad", it) }.getOrNull()
fun hasPendingScreenKeyMigration(context: Context) = !prefs(context).contains(LegacyScreenKeyMigration.BACKUP) && LegacyScreenKeyMigration.snapshot(context) != null
fun hasLibraryRecovery(context: Context) = prefs(context).all.keys.any { it.startsWith(RECOVERY) }
@Synchronized
fun update(context: Context, change: (InputLibrary) -> InputLibrary): InputLibrary {
val p = prefs(context)
val stored = p.getString(KEY, null)
val invalid = stored != null && decode(stored) == null
require(!invalid || runCatching { JSONObject(stored!!).getInt("version") }.getOrNull()?.let { it <= VERSION } != false) {
"The saved input library cannot be edited by this version."
}
val updated = change(read(context)).validate()
if (invalid) {
val encoded = encode(updated)
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
check(p.edit().putString(RECOVERY + UUID.nameUUIDFromBytes(stored!!.toByteArray(Charsets.UTF_8)), stored)
.putString(KEY, encoded).commit()) { "Could not save the input library and its recovery copy." }
} else write(context, updated)
return updated
}
private fun write(context: Context, library: InputLibrary) {
val encoded = encode(library.validate())
require(encoded.length <= 2_000_000) { "The input library is too large to save." }
prefs(context).edit().putString(KEY, encoded).apply()
}
private fun migrate(context: Context): InputLibrary {
val profiles = mutableListOf<SavedInputProfile>()
val stock = InputStockLayouts.layouts()
val layouts = (listOf(SavedInputLayout(OVERLAY, "Built-in gamepad", template = InputLayoutTemplate.OVERLAY)) + stock).toMutableList()
val bindings = InputStockLayouts.presets(stock).toMutableList()
for (id in listOf(InputProfile.GAME, InputProfile.DESKTOP)) {
val old = InputProfiles.readLegacy(context, id)
val name = if (id == InputProfile.GAME) "Game" else "Desktop"
val legacySurface = InputSurfaceLayout.hasLegacy(context, id) ||
old.bindings[InputRole.SECONDARY] != InputProfile.defaults(id).bindings[InputRole.SECONDARY]
val layout = if (legacySurface) SavedInputLayout("$id.layout", "$name touch", InputSurfaceLayout.readLegacy(context, id)).also { layouts += it }
else stock.first { it.id == if (id == InputProfile.DESKTOP) TRACKPAD else DECK }
val parts = InputRole.entries.associateWith { role ->
if (role == InputRole.SECONDARY && !legacySurface) {
val preset = bindings.first { it.layoutId == layout.id }
return@associateWith InputPart(preset.id, layout.id)
}
val preset = SavedInputBindings("$id.${role.name.lowercase()}", "$name / ${role.name.lowercase()}",
role == InputRole.PHYSICAL, if (role == InputRole.SECONDARY) layout.id else null,
old.bindings[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) old.keys else emptyList())
bindings += preset
InputPart(preset.id, when (role) { InputRole.PHYSICAL -> null; InputRole.PRIMARY -> OVERLAY; InputRole.SECONDARY -> layout.id })
}
profiles += SavedInputProfile(id, name, id, parts)
}
val selected = prefs(context).getString("selected", InputProfiles.AUTO)
?.takeIf { it == InputProfiles.AUTO || profiles.any { p -> p.id == it } } ?: InputProfiles.AUTO
return InputLibrary(profiles, layouts, bindings, selected).validate()
}
fun setAdaptiveSticks(context: Context, layoutId: String, on: Boolean): Boolean = runCatching {
update(context) { library ->
library.copy(layouts = library.layouts.map { layout ->
if (layout.id != layoutId || !layout.editable) layout
else layout.copy(elements = layout.elements.map { if (it.kind == InputElementKind.STICK) it.copy(floating = on, hideWhenIdle = on) else it })
})
}
}.onSuccess { ControllerPrefs.setAdaptiveSticks(context, on) }.isSuccess
fun setQuickButton(context: Context, layoutId: String, button: InputCommand, shown: Boolean): Boolean = runCatching {
update(context) { library -> library.layouts.firstOrNull { it.id == layoutId && it.editable }?.let { library.withLayout(it.withQuickButton(button, shown)) } ?: library }
}.isSuccess
fun defaultPositions(context: Context, library: InputLibrary, layout: SavedInputLayout): List<InputSurfaceElement>? {
val origin = layout.origin ?: layout.id
InputStockLayouts.layouts().firstOrNull { it.id == origin }?.let { return it.elements }
if (origin == OVERLAY || origin.startsWith("migrated.layout.")) {
val (surfaceContext, metrics) = primaryDisplay(context)
val size = if (origin == OVERLAY) listOf(metrics.widthPixels, metrics.heightPixels)
else origin.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
return size.takeIf { it.size == 2 && it.all { side -> side > 0 } }?.let { LegacyGamepadLayout(surfaceContext, it[0], it[1]).elements() }
}
return library.layouts.firstOrNull { it.id == origin && it.id != layout.id }?.elements
}
@Suppress("DEPRECATION")
internal fun primaryDisplay(context: Context): Pair<Context, DisplayMetrics> {
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
val surfaceContext = display?.let(context::createDisplayContext) ?: context
return surfaceContext to DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
}
private fun restyleGamepads(context: Context, library: InputLibrary): InputLibrary {
if (library.layouts.none { it.id == OVERLAY || it.id.startsWith("migrated.layout.") }) return library
val (surfaceContext, metrics) = primaryDisplay(context)
return library.copy(layouts = library.layouts.map { layout ->
val size = when {
layout.id == OVERLAY -> listOf(metrics.widthPixels, metrics.heightPixels)
layout.id.startsWith("migrated.layout.") -> layout.id.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() }
else -> emptyList()
}
if (size.size != 2 || size.any { it <= 0 }) layout
else layout.copy(elements = LegacyGamepadLayout(surfaceContext, size[0], size[1]).restyle(layout.elements))
})
}
private fun upgradeOverlay(context: Context, library: InputLibrary): InputLibrary {
if (library.layouts.none { it.template == InputLayoutTemplate.OVERLAY }) return library
val (surfaceContext, metrics) = primaryDisplay(context)
val settings = ControllerPrefs.read(context)
val elements = LegacyGamepadLayout(surfaceContext, metrics.widthPixels.coerceAtLeast(1), metrics.heightPixels.coerceAtLeast(1)).elements()
val style = InputSurfaceStyle(settings.tint, settings.opacity)
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.OVERLAY }.associate { old ->
old.id to old.copy(name = "Gamepad", elements = elements, template = InputLayoutTemplate.CUSTOM, style = style,
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
}
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
val migrated = mutableMapOf<Pair<String, String>, String>()
for (profile in library.profiles) {
val parts = profile.parts.mapValues { (role, part) ->
val layout = replacements[part.layout] ?: return@mapValues part
val key = part.bindings to layout.id
val id = migrated.getOrPut(key) {
val old = library.preset(part.bindings)
val newId = migrationBindingId(result, old.id, layout.id)
val keyboard = elements.filter { it.id == "builtin:keyboard" }.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) }
val clicks = if (settings.stickClick) old.clicks else old.clicks + elements.filter { it.hasClick }.associate { it.id to InputAction.Disabled }
result = result.withPreset(old.copy(id = newId, layoutId = layout.id, elements = old.elements + keyboard, clicks = clicks))
newId
}
part.copy(bindings = id)
}
result = result.withProfile(profile.copy(parts = parts))
}
val oldBindings = migrated.keys.map { it.first }.toSet()
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
// Older editor layouts were resolution-specific. Keep each as a selectable saved layout.
for (key in ControllerPrefs.prefs(context).all.keys.filter { it.startsWith("layout.") }.sorted()) {
val size = key.removePrefix("layout.").split('x').mapNotNull { it.toIntOrNull() }
if (size.size != 2 || size.any { it <= 0 } || result.layouts.size >= 128) continue
if (size[0] == metrics.widthPixels && size[1] == metrics.heightPixels) continue
val id = "migrated.$key"
if (result.layouts.none { it.id == id }) result = result.withLayout(SavedInputLayout(id, "Gamepad ${size[0]} × ${size[1]}",
LegacyGamepadLayout(surfaceContext, size[0], size[1]).elements(), style = style, aspect = (size[0].toFloat() / size[1]).coerceIn(.05f, 20f)))
}
return result.validate()
}
private fun neutralStockLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
if ((layout.id == DECK || layout.id == TRACKPAD) && layout.style.tint == ControllerPrefs.STEAM_BLUE) layout.copy(style = layout.style.copy(tint = null))
else layout
})
private fun upgradeStockLayouts(library: InputLibrary): InputLibrary {
val stock = InputStockLayouts.layouts()
val replacements = library.layouts.filter { it.template == InputLayoutTemplate.DECK || it.template == InputLayoutTemplate.TRACKPAD }
.associate { old -> old.id to stock.first { it.id == if (old.template == InputLayoutTemplate.DECK) DECK else TRACKPAD }.copy(id = old.id, name = old.name) }
var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it })
val migrated = mutableMapOf<Pair<String, String>, String>()
for (profile in library.profiles) {
val parts = profile.parts.mapValues { (_, part) ->
val layout = replacements[part.layout] ?: return@mapValues part
val id = migrated.getOrPut(part.bindings to layout.id) {
val old = library.preset(part.bindings)
val id = migrationBindingId(result, old.id, layout.id)
val original = library.layouts.first { it.id == layout.id }
val stockLayout = layout.copy(id = if (original.template == InputLayoutTemplate.TRACKPAD) TRACKPAD else DECK)
result = result.withPreset(old.copy(id = id, layoutId = layout.id,
elements = InputStockLayouts.actions(stockLayout) + old.elements, clicks = InputStockLayouts.clicks(stockLayout) + old.clicks))
id
}
part.copy(bindings = id)
}
result = result.withProfile(profile.copy(parts = parts))
}
val oldBindings = migrated.keys.map { it.first }.toSet()
result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() })
return addStockLayouts(result)
}
private fun addStockLayouts(library: InputLibrary): InputLibrary {
val stock = InputStockLayouts.layouts()
var result = library
for (layout in stock) if (result.layouts.none { it.id == layout.id }) result = result.withLayout(layout)
for (preset in InputStockLayouts.presets(stock)) if (result.bindings.none { it.id == preset.id }) result = result.withPreset(preset)
return result.validate()
}
private fun barStockLayouts(library: InputLibrary): InputLibrary {
var result = library
for (stock in InputStockLayouts.layouts().filter { it.id == DECK || it.id == TRACKPAD }) {
val saved = result.layouts.firstOrNull { it.id == stock.id } ?: continue
val (commands, rest) = saved.elements.partition { it.id.startsWith("command:") }
val untouched = commands.isNotEmpty() && commands.all { it.control == InputControl.A && it.kind == InputElementKind.BUTTON } &&
rest.size == stock.elements.size && rest.all { e -> stock.elements.any { s -> s.id == e.id && s.copy(y = e.y, height = e.height) == e &&
kotlin.math.abs(s.y + s.height - e.y - e.height) < 1e-3f } }
if (untouched) result = result.withLayout(saved.copy(elements = stock.elements))
}
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)
var id = base
var suffix = 0
while (library.bindings.any { it.id == id }) id = "$base.${++suffix}"
return id
}
private fun scopeLayouts(library: InputLibrary): InputLibrary = library.copy(layouts = library.layouts.map { layout ->
val roles = library.profiles.flatMap { p -> p.parts.filterValues { it.layout == layout.id }.keys } +
library.selections.values.flatMap { s -> s.parts.filterValues { library.preset(it).layoutId == layout.id }.keys }
val screen = when {
InputRole.PRIMARY in roles && InputRole.SECONDARY in roles -> InputLayoutScreen.BOTH
InputRole.PRIMARY in roles -> InputLayoutScreen.MAIN
InputRole.SECONDARY in roles -> InputLayoutScreen.SECONDARY
layout.id == OVERLAY || layout.id.startsWith("migrated.layout.") -> InputLayoutScreen.MAIN
else -> InputLayoutScreen.SECONDARY
}
layout.copy(screen = screen)
})
internal fun encode(library: InputLibrary): String {
val layouts = JSONArray()
for (layout in library.layouts) {
val elements = JSONArray()
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("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))
}
val bindings = JSONArray()
for (b in library.bindings) {
val controls = JSONObject(); b.controls.forEach { (c, a) -> controls.put(c.id, InputProfiles.encode(a)) }
val elements = JSONObject(); b.elements.forEach { (id, a) -> elements.put(id, InputProfiles.encode(a)) }
val clicks = JSONObject(); b.clicks.forEach { (id, a) -> clicks.put(id, InputProfiles.encode(a)) }
val keys = JSONArray()
b.keys.forEach { binding -> keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", InputProfiles.encode(binding.action))) }
bindings.put(JSONObject().put("id", b.id).put("name", b.name).put("physical", b.physical)
.put("layout", b.layoutId).put("controls", controls).put("elements", elements).put("keys", keys).put("clicks", clicks))
}
val profiles = JSONArray()
for (p in library.profiles) {
val parts = JSONObject()
p.parts.forEach { (role, part) -> parts.put(role.name, JSONObject().put("bindings", part.bindings).put("layout", part.layout)) }
profiles.put(JSONObject().put("id", p.id).put("name", p.name).put("base", p.base).put("parts", parts))
}
val selections = JSONObject()
for ((mode, selection) in library.selections) {
val parts = JSONObject()
selection.parts.forEach { (role, id) -> parts.put(role.name, id) }
selections.put(mode, JSONObject().put("profile", selection.profile).put("parts", parts).put("profileSelected", selection.profileSelected))
}
return JSONObject().put("selections", selections).put("version", VERSION).put("selected", library.selected).put("steam", library.steam).put("desktop", library.desktop)
.put("profiles", profiles).put("layouts", layouts).put("bindings", bindings).toString()
}
internal fun decode(raw: String): InputLibrary? = runCatching {
require(raw.length <= 2_000_000)
val doc = JSONObject(raw); require(doc.getInt("version") in 2..VERSION)
val layouts = doc.getJSONArray("layouts").objects(256).map { layout ->
val elements = layout.getJSONArray("elements").objects(64).map { e ->
InputSurfaceElement(requireNotNull(InputControl.fromId(e.getString("control"))), e.getDouble("x").toFloat(),
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.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),
layout.optJSONArray("hidden")?.let { hidden -> (0 until hidden.length()).map { InputCommand.valueOf(hidden.getString(it)) }.toSet() }.orEmpty()),
InputLayoutScreen.valueOf(layout.optString("screen", InputLayoutScreen.BOTH.name)), layout.optDouble("aspect", 0.0).toFloat(), layout.optionalString("origin"),
layout.optDouble("scale", 1.0).toFloat())
}
val bindings = doc.getJSONArray("bindings").objects(512).map { b ->
val controls = b.getJSONObject("controls").actions(28).mapKeys { requireNotNull(InputControl.fromId(it.key)) }
val keys = b.getJSONArray("keys").objects(64).map { k -> PhysicalInputBinding(
PhysicalInputKey(k.getString("device"), k.getString("name"), k.getInt("keyCode"), k.getInt("scanCode")),
requireNotNull(InputProfiles.decode(k.getJSONObject("action")))) }
SavedInputBindings(b.getString("id"), b.getString("name"), b.getBoolean("physical"), b.optionalString("layout"),
controls, b.getJSONObject("elements").actions(64), keys, b.optJSONObject("clicks")?.actions(64).orEmpty())
}
val profiles = doc.getJSONArray("profiles").objects(64).map { p ->
val parts = p.getJSONObject("parts")
SavedInputProfile(p.getString("id"), p.getString("name"), p.getString("base"), InputRole.entries.associateWith { role ->
val part = parts.getJSONObject(role.name); InputPart(part.getString("bindings"), part.optionalString("layout"))
})
}
val selections = doc.optJSONObject("selections")?.let { saved ->
require(saved.length() <= 2)
saved.keys().asSequence().associateWith { mode ->
val value = saved.getJSONObject(mode)
val parts = value.getJSONObject("parts")
require(parts.length() <= InputRole.entries.size)
val profile = value.getString("profile")
InputSelection(profile, parts.keys().asSequence().associate { role -> InputRole.valueOf(role) to parts.getString(role) },
value.optBoolean("profileSelected", profile != InputProfiles.AUTO || parts.length() == 0))
}
}.orEmpty()
InputLibrary(profiles, layouts, bindings, doc.getString("selected"), doc.getString("steam"), doc.getString("desktop"), selections).validate()
}.getOrNull()
private fun JSONArray.objects(limit: Int): List<JSONObject> {
require(length() <= limit)
return (0 until length()).map { getJSONObject(it) }
}
private fun JSONObject.actions(limit: Int): Map<String, InputAction> {
require(length() <= limit)
return keys().asSequence().associateWith { requireNotNull(InputProfiles.decode(getJSONObject(it))) }
}
private fun JSONObject.optionalString(key: String): String? = if (isNull(key)) null else getString(key)
}
@@ -0,0 +1,304 @@
package com.droiddeck.launcher.input
import java.util.UUID
enum class InputLayoutTemplate { CUSTOM, OVERLAY, DECK, TRACKPAD }
enum class InputLayoutScreen { MAIN, SECONDARY, BOTH;
fun supports(role: InputRole) = role != InputRole.PHYSICAL && (this == BOTH ||
if (role == InputRole.PRIMARY) this == MAIN else this == SECONDARY)
}
data class InputSurfaceStyle(val tint: Int? = ControllerPrefs.STEAM_BLUE, val opacity: Int = 100, val hidden: Set<InputCommand> = emptySet()) {
fun hides(element: InputSurfaceElement) = element.quickButton?.let { it in hidden } == true
fun shown(elements: List<InputSurfaceElement>) = if (hidden.isEmpty()) elements else elements.filterNot(::hides)
}
data class SavedInputLayout(
val id: String, val name: String, val elements: List<InputSurfaceElement> = emptyList(),
val template: InputLayoutTemplate = InputLayoutTemplate.CUSTOM,
val style: InputSurfaceStyle = InputSurfaceStyle(),
val screen: InputLayoutScreen = InputLayoutScreen.BOTH,
val aspect: Float = 0f,
val origin: String? = null,
val scale: Float = 1f,
) {
val editable get() = template == InputLayoutTemplate.CUSTOM
val quickButtons get() = QUICK_BUTTONS.filter { button -> elements.any { it.quickButton == button } }
fun withQuickButton(button: InputCommand, shown: Boolean) =
copy(style = style.copy(hidden = if (shown) style.hidden - button else style.hidden + button))
fun derived(id: String, name: String) = copy(id = id, name = name, origin = origin ?: this.id)
val adaptiveSticks get() = elements.filter { it.kind == InputElementKind.STICK }.takeIf { editable && it.isNotEmpty() }?.all { it.floating && it.hideWhenIdle }
fun supports(role: InputRole) = screen.supports(role) && when (template) {
InputLayoutTemplate.OVERLAY -> role == InputRole.PRIMARY
InputLayoutTemplate.DECK, InputLayoutTemplate.TRACKPAD -> role == InputRole.SECONDARY
InputLayoutTemplate.CUSTOM -> true
}
}
data class SavedInputBindings(
val id: String, val name: String, val physical: Boolean,
val layoutId: String? = null,
val controls: Map<InputControl, InputAction> = emptyMap(),
val elements: Map<String, InputAction> = emptyMap(),
val keys: List<PhysicalInputBinding> = emptyList(),
val clicks: Map<String, InputAction> = emptyMap(),
) {
fun compatible(layout: SavedInputLayout?) = if (physical) layout == null
else layout != null && if (layout.editable) layoutId == layout.id else layoutId == null
fun action(element: InputSurfaceElement) = elements[element.id]
?: if (element.id == "legacy:${element.control.id}") controls[element.control] ?: InputAction.Native()
else InputAction.Disabled
fun clickAction(element: InputSurfaceElement) = clicks[element.id]
?: defaultElementClick(element, action(element)) { controls[it] ?: InputAction.Native() }
}
data class InputSelection(
val profile: String = InputProfiles.AUTO, val parts: Map<InputRole, String> = emptyMap(), val profileSelected: Boolean = false,
)
data class InputPart(val bindings: String, val layout: String? = null)
data class SavedInputProfile(val id: String, val name: String, val base: String, val parts: Map<InputRole, InputPart>) {
fun binding(role: InputRole) = parts.getValue(role).bindings
}
data class InputLibrary(
val profiles: List<SavedInputProfile>, val layouts: List<SavedInputLayout>, val bindings: List<SavedInputBindings>,
val selected: String = InputProfiles.AUTO,
val steam: String = InputProfile.GAME, val desktop: String = InputProfile.DESKTOP,
val selections: Map<String, InputSelection> = emptyMap(),
) {
fun profile(id: String) = profiles.first { it.id == id }
fun preset(id: String) = bindings.first { it.id == id }
fun layout(profile: String, role: InputRole) = profiles.firstOrNull { it.id == profile }?.parts?.get(role)?.layout
?.let { id -> layouts.firstOrNull { it.id == id } }
private fun mode(isDesktop: Boolean) = if (isDesktop) InputProfile.DESKTOP else InputProfile.GAME
fun selection(isDesktop: Boolean) = selections[mode(isDesktop)] ?: InputSelection(selected)
fun activeId(isDesktop: Boolean): String {
val id = selection(isDesktop).profile
return if (id == InputProfiles.AUTO) { if (isDesktop) desktop else steam } else id
}
fun selectProfile(isDesktop: Boolean, id: String) = copy(selections = selections + (mode(isDesktop) to InputSelection(id, profileSelected = true)))
fun selectPart(isDesktop: Boolean, role: InputRole, bindingsId: String): InputLibrary {
val previous = selection(isDesktop)
return copy(selections = selections + (mode(isDesktop) to previous.copy(parts = previous.parts + (role to bindingsId))))
}
fun activePart(isDesktop: Boolean, role: InputRole): InputPart {
val binding = selection(isDesktop).parts[role]?.let(::preset)
return if (binding != null) InputPart(binding.id, binding.layoutId) else profile(activeId(isDesktop)).parts.getValue(role)
}
fun activeLayout(isDesktop: Boolean, role: InputRole) = activePart(isDesktop, role).layout?.let { id -> layouts.first { it.id == id } }
fun panelPart(isDesktop: Boolean, panel: String?): InputPart =
panel?.let { id -> bindings.firstOrNull { it.id == id } }?.let { InputPart(it.id, it.layoutId) } ?: activePart(isDesktop, InputRole.SECONDARY)
fun panelLayout(isDesktop: Boolean, panel: String?) = panelPart(isDesktop, panel).layout?.let { id -> layouts.firstOrNull { it.id == id } }
fun compileActive(isDesktop: Boolean, panel: String? = null): InputProfile = compileParts(activeId(isDesktop), InputRole.entries.associateWith {
preset(if (it == InputRole.SECONDARY) panelPart(isDesktop, panel).bindings else activePart(isDesktop, it).bindings)
})
fun saveSelection(isDesktop: Boolean, name: String, id: String = newInputId()): InputLibrary {
val current = profile(activeId(isDesktop))
var result = withProfile(SavedInputProfile(id, inputName(name), current.base, InputRole.entries.associateWith { activePart(isDesktop, it) }))
for (role in InputRole.entries) result = result.usePart(id, role, id, shared = false)
return result.selectProfile(isDesktop, id)
}
fun compile(id: String): InputProfile {
val profile = profile(id)
val parts = InputRole.entries.associateWith { preset(profile.binding(it)) }
return compileParts(id, parts)
}
private fun compileParts(id: String, parts: Map<InputRole, SavedInputBindings>): InputProfile {
return InputProfile(id, parts.mapValues { it.value.controls }.filterValues { it.isNotEmpty() },
parts.getValue(InputRole.PHYSICAL).keys,
parts.mapValues { it.value.elements }.filterValues { it.isNotEmpty() },
parts.mapValues { it.value.clicks }.filterValues { it.isNotEmpty() })
}
fun usersOfBindings(id: String) = profiles.filter { p -> p.parts.values.any { it.bindings == id } }
fun selectedBindings(id: String) = selections.values.any { id in it.parts.values }
fun usersOfLayout(id: String) = profiles.filter { p -> p.parts.values.any { it.layout == id } }
fun withProfile(value: SavedInputProfile) = copy(profiles = profiles.filterNot { it.id == value.id } + value)
fun withPreset(value: SavedInputBindings) = copy(bindings = bindings.filterNot { it.id == value.id } + value)
fun withLayout(value: SavedInputLayout) = copy(layouts = layouts.filterNot { it.id == value.id } + value,
bindings = bindings.map { preset -> if (preset.layoutId != value.id) preset else preset.copy(
elements = preset.elements.filter { (id, action) -> value.elements.any { it.id == id && action.validFor(it) } },
clicks = preset.clicks.filter { (id, action) -> value.elements.any { it.id == id && it.hasClick && action.validForClick() } }) })
fun duplicateProfile(id: String, name: String, newId: String = newInputId(), sharePhysical: Boolean = false): InputLibrary {
require(profiles.size < 64)
var result = withProfile(profile(id).copy(id = newId, name = inputName(name)))
for (role in InputRole.entries) result = result.usePart(newId, role, id, shared = sharePhysical && role == InputRole.PHYSICAL)
return result
}
fun usePart(profileId: String, role: InputRole, sourceId: String, shared: Boolean): InputLibrary {
val target = profile(profileId)
val source = profile(sourceId).parts.getValue(role)
if (shared) return withProfile(target.copy(parts = target.parts + (role to source)))
val sourceLayout = layout(sourceId, role)
val name = (target.name + " / " + role.name.lowercase()).take(80)
val copiedLayout = sourceLayout?.takeIf { it.editable }?.derived(newInputId(), name)
val copiedBindings = preset(source.bindings).copy(id = newInputId(), name = name,
layoutId = copiedLayout?.id ?: preset(source.bindings).layoutId)
return (if (copiedLayout == null) this else withLayout(copiedLayout)).withPreset(copiedBindings)
.withProfile(target.copy(parts = target.parts + (role to InputPart(copiedBindings.id, copiedLayout?.id ?: source.layout))))
}
fun useLayout(profileId: String, role: InputRole, layoutId: String, shared: Boolean): InputLibrary {
val source = layouts.first { it.id == layoutId }
require(source.supports(role))
val target = profile(profileId)
val layout = if (shared || !source.editable) source else source.derived(newInputId(), (target.name + " / " + source.name).take(80))
val previous = preset(target.binding(role))
val actions = if (previous.compatible(source)) previous else bindings.firstOrNull { it.compatible(source) } ?: SavedInputBindings(newInputId(), target.name, false,
controls = if (source.editable) emptyMap() else InputProfile.defaults(target.base).bindings[role].orEmpty(),
elements = if (source.editable) source.elements.associate { it.id to InputAction.Disabled } else emptyMap())
val copied = actions.copy(id = newInputId(), name = (target.name + " / " + role.name.lowercase()).take(80),
layoutId = layout.id.takeIf { layout.editable })
return withLayout(layout).withPreset(copied)
.withProfile(target.copy(parts = target.parts + (role to InputPart(copied.id, layout.id))))
}
fun copyBindings(id: String, name: String, profileId: String? = null, role: InputRole? = null, newId: String = newInputId()): InputLibrary {
require(bindings.size < 512)
var result = withPreset(preset(id).copy(id = newId, name = inputName(name)))
if (profileId != null && role != null) result = result.chooseBindings(profileId, role, newId)
return result
}
fun chooseBindings(profileId: String, role: InputRole, id: String): InputLibrary {
val profile = profile(profileId)
require(preset(id).compatible(layout(profileId, role)))
return withProfile(profile.copy(parts = profile.parts + (role to profile.parts.getValue(role).copy(bindings = id))))
}
fun chooseLayout(profileId: String, role: InputRole, id: String): InputLibrary {
val layout = layouts.first { it.id == id }
require(layout.supports(role))
val profile = profile(profileId)
val previous = preset(profile.binding(role))
val compatible = bindings.firstOrNull { it.id == previous.id && it.compatible(layout) }
?: bindings.firstOrNull { it.compatible(layout) }
?: SavedInputBindings(newInputId(), layout.name, false, if (layout.editable) layout.id else null)
return withPreset(compatible).withProfile(profile.copy(parts = profile.parts + (role to InputPart(compatible.id, id))))
}
fun copyLayout(id: String, name: String, newId: String = newInputId()): InputLibrary {
val source = layouts.first { it.id == id }
require(source.editable)
return withLayout(source.derived(newId, inputName(name)))
}
fun removeProfile(id: String): InputLibrary {
require(id != InputProfile.GAME && id != InputProfile.DESKTOP)
return copy(profiles = profiles.filterNot { it.id == id }, selected = selected.takeUnless { it == id } ?: InputProfiles.AUTO,
steam = steam.takeUnless { it == id } ?: InputProfile.GAME, desktop = desktop.takeUnless { it == id } ?: InputProfile.DESKTOP,
selections = selections.mapValues { (_, selection) -> if (selection.profile == id) selection.copy(profile = InputProfiles.AUTO) else selection })
}
fun removeLayout(id: String): InputLibrary {
require(usersOfLayout(id).isEmpty() && bindings.none { it.layoutId == id && selectedBindings(it.id) } && layouts.first { it.id == id }.editable)
return copy(layouts = layouts.filterNot { it.id == id }, bindings = bindings.filterNot { it.layoutId == id })
}
fun removeBindings(id: String): InputLibrary {
require(usersOfBindings(id).isEmpty() && !selectedBindings(id))
return copy(bindings = bindings.filterNot { it.id == id })
}
fun resetProfile(id: String): InputLibrary {
val p = profile(id)
var result = this
val defaults = InputProfile.defaults(p.base)
val parts = p.parts.mapValues { (role, part) ->
val previous = preset(part.bindings)
val isPrivate = usersOfBindings(part.bindings).all { it.id == id }
val preset = SavedInputBindings(if (isPrivate) previous.id else newInputId(),
if (isPrivate) previous.name else (p.name + " / " + role.name.lowercase()).take(80), role == InputRole.PHYSICAL,
part.layout?.takeIf { layout(id, role)?.editable == true }, defaults.bindings[role].orEmpty(),
elements = (layout(id, role)?.let { InputStockLayouts.actions(it) } ?: emptyMap()) + layout(id, role)?.elements.orEmpty().filter { it.id == "builtin:keyboard" }
.associate { it.id to InputAction.Command(InputCommand.KEYBOARD) })
result = result.withPreset(preset)
part.copy(bindings = preset.id)
}
return result.withProfile(p.copy(parts = parts))
}
fun validate(): InputLibrary {
require(profiles.size in 2..64 && layouts.size in 3..256 && bindings.size in 1..512)
fun unique(ids: List<String>) { require(ids.all { it.isNotBlank() && it.length <= 128 } && ids.distinct().size == ids.size) }
unique(profiles.map { it.id }); unique(layouts.map { it.id }); unique(bindings.map { it.id })
require(profiles.any { it.id == InputProfile.GAME } && profiles.any { it.id == InputProfile.DESKTOP })
require(selected == InputProfiles.AUTO || profiles.any { it.id == selected })
require(profiles.any { it.id == steam } && profiles.any { it.id == desktop })
for (layout in layouts) {
require(layout.style.opacity in 1..100 && layout.style.hidden.all { it in QUICK_BUTTONS })
require(layout.aspect == 0f || layout.aspect.isFinite() && layout.aspect in .05f..20f)
require(layout.scale.isFinite() && layout.scale in .5f..2f)
inputName(layout.name); require(layout.elements.size <= 64); unique(layout.elements.map { it.id })
for (e in layout.elements) {
inputName(e.name)
require(e.icon == 0 || InputButtonIcons.has(e.icon))
require(e.image.isEmpty() || e.image.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && e.image != "." && e.image != "..")
require(e.opacity.isFinite() && e.opacity in 0.01f..1f)
require(!e.toggle || e.kind == InputElementKind.BUTTON || e.kind == InputElementKind.TRIGGER)
require(listOf(e.x, e.y, e.width, e.height).all { it.isFinite() })
require(e == e.bounded())
require(if (e.id.startsWith("legacy:")) e.id == "legacy:${e.control.id}" && e.kind == InputElementKind.from(e.control)
else e.control == e.kind.control)
}
}
for (preset in bindings) {
inputName(preset.name)
require(preset.controls.all { (c, a) -> a.validFor(c) })
require(preset.elements.size <= 64 && preset.keys.size <= 64 && preset.clicks.size <= 64)
require(preset.keys.map { Triple(it.key.device, it.key.keyCode, it.key.scanCode) }.distinct().size == preset.keys.size)
require(preset.physical || preset.keys.isEmpty())
val layout = preset.layoutId?.let { id -> layouts.first { it.id == id } }
require(!preset.physical || layout == null && preset.elements.isEmpty() && preset.clicks.isEmpty())
require(preset.elements.all { (id, action) -> layout?.elements?.firstOrNull { it.id == id }?.let { action.validFor(it) } == true })
require(preset.clicks.all { (id, action) -> layout?.elements?.any { it.id == id && it.hasClick } == true && action.validForClick() })
require(preset.keys.all { it.key.device.isNotBlank() && it.key.keyCode >= 0 && it.key.scanCode >= 0 &&
it.action.validFor(InputControl.A) && (it.action !is InputAction.Native || it.action.target == null) })
}
require(selections.keys.all { it == InputProfile.GAME || it == InputProfile.DESKTOP })
for (selection in selections.values) {
require(selection.profile == InputProfiles.AUTO || profiles.any { it.id == selection.profile })
for ((role, id) in selection.parts) {
val binding = preset(id)
require(binding.physical == (role == InputRole.PHYSICAL))
val layout = binding.layoutId?.let { lid -> layouts.first { it.id == lid } }
require(binding.compatible(layout) && (layout == null || layout.supports(role)))
}
}
for (p in profiles) {
inputName(p.name); require(p.base == InputProfile.GAME || p.base == InputProfile.DESKTOP)
require(p.parts.keys == InputRole.entries.toSet())
for ((role, part) in p.parts) {
val layout = part.layout?.let { id -> layouts.first { it.id == id } }
require(if (role == InputRole.PHYSICAL) layout == null else layout?.supports(role) == true)
require(preset(part.bindings).compatible(layout))
}
}
return this
}
}
internal fun inputName(value: String): String = value.trim().also { require(it.isNotEmpty() && it.length <= 80) }
fun newInputId(): String = UUID.randomUUID().toString()
internal fun InputAction.validFor(element: InputSurfaceElement): Boolean = when {
this !is InputAction.Native -> validFor(element.control)
element.id.startsWith("legacy:") -> validFor(element.control)
element.kind == InputElementKind.DPAD -> target == null
target == null -> false
element.kind == InputElementKind.TRACKPAD -> target.isTrackpad
element.kind == InputElementKind.STICK -> target == InputControl.LS || target == InputControl.RS
element.kind == InputElementKind.TRIGGER -> target == InputControl.LT || target == InputControl.RT
else -> target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
}
@@ -0,0 +1,35 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
class InputMotion(private val router: () -> InputRouter?) : Choreographer.FrameCallback {
private val handler = Handler(Looper.getMainLooper())
private var scheduled = false
private var lastFrame = 0L
private val start = Runnable {
if (!scheduled) {
scheduled = true
lastFrame = System.nanoTime()
Choreographer.getInstance().postFrameCallback(this)
}
}
fun request() {
if (Looper.myLooper() == Looper.getMainLooper()) start.run() else handler.post(start)
}
override fun doFrame(frameTimeNanos: Long) {
val dt = (frameTimeNanos - lastFrame).coerceAtLeast(0) / 1_000_000_000f
lastFrame = frameTimeNanos
if (router()?.tick(dt) == true) Choreographer.getInstance().postFrameCallback(this)
else scheduled = false
}
fun stop() {
handler.removeCallbacks(start)
Choreographer.getInstance().removeFrameCallback(this)
scheduled = false
}
}
@@ -0,0 +1,107 @@
package com.droiddeck.launcher.input
enum class InputRole { PHYSICAL, PRIMARY, SECONDARY }
enum class InputControl(val id: String, val label: String, val dimensions: Int = 1) {
A("a", "A"), B("b", "B"), X("x", "X"), Y("y", "Y"),
LB("lb", "L1"), RB("rb", "R1"), SELECT("select", "View / Select"), START("start", "Menu / Start"),
L3("l3", "L3"), R3("r3", "R3"), GUIDE("guide", "Steam / Guide"), QAM("qam", "Quick access"),
UP("up", "D-pad up"), RIGHT("right", "D-pad right"), DOWN("down", "D-pad down"), LEFT("left", "D-pad left"),
LT("lt", "L2"), RT("rt", "R2"), LS("ls", "Left stick", 2), RS("rs", "Right stick", 2),
L4("l4", "L4"), R4("r4", "R4"), L5("l5", "L5"), R5("r5", "R5"),
LEFT_PAD("left-pad", "Left trackpad", 2), RIGHT_PAD("right-pad", "Right trackpad", 2),
LEFT_CLICK("left-click", "Left trackpad click"), RIGHT_CLICK("right-click", "Right trackpad click");
val isPad: Boolean get() = ordinal <= RS.ordinal
val isTrackpad: Boolean get() = this == LEFT_PAD || this == RIGHT_PAD
companion object {
val padControls = entries.filter { it.isPad }.toTypedArray()
fun fromId(id: String) = entries.firstOrNull { it.id == id }
}
}
enum class InputCommand { MENU, KEYBOARD, STEAM, QUICK_ACCESS }
sealed interface InputAction {
data class Native(val target: InputControl? = null) : InputAction
data object Disabled : InputAction
data class Keys(val codes: List<Int>) : InputAction
data class MouseButton(val code: Int) : InputAction
data class Command(val command: InputCommand) : InputAction
data class Directions(val up: Int, val right: Int, val down: Int, val left: Int, val diagonals: List<Int>? = null) : InputAction
data class Pointer(val speed: Float = 900f) : InputAction
data class Scroll(val speed: Float = 10f, val horizontal: Boolean = false) : InputAction
}
data class InputProfile(
val id: String,
val bindings: Map<InputRole, Map<InputControl, InputAction>> = emptyMap(),
val keys: List<PhysicalInputBinding> = emptyList(),
val elements: Map<InputRole, Map<String, InputAction>> = emptyMap(),
val clicks: Map<InputRole, Map<String, InputAction>> = emptyMap(),
) {
fun action(role: InputRole, control: InputControl): InputAction =
bindings[role]?.get(control) ?: InputAction.Native()
fun withBinding(role: InputRole, control: InputControl, action: InputAction) =
copy(bindings = bindings + (role to (bindings[role].orEmpty() + (control to action))))
fun elementAction(role: InputRole, id: String, control: InputControl): InputAction =
elements[role]?.get(id) ?: if (id == "legacy:${control.id}") action(role, control) else InputAction.Disabled
fun elementClickAction(role: InputRole, element: InputSurfaceElement): InputAction = clicks[role]?.get(element.id)
?: defaultElementClick(element, elementAction(role, element.id, element.control)) { action(role, it) }
companion object {
const val GAME = "game"
const val DESKTOP = "desktop"
fun defaults(id: String): InputProfile {
if (id != DESKTOP) return InputProfile(GAME)
val desktop = mapOf(
InputControl.LS to InputAction.Pointer(), InputControl.RS to InputAction.Scroll(),
InputControl.A to InputAction.MouseButton(272), InputControl.B to InputAction.MouseButton(273),
InputControl.X to InputAction.Keys(listOf(28)), InputControl.Y to InputAction.Keys(listOf(1)),
InputControl.UP to InputAction.Keys(listOf(103)), InputControl.RIGHT to InputAction.Keys(listOf(106)),
InputControl.DOWN to InputAction.Keys(listOf(108)), InputControl.LEFT to InputAction.Keys(listOf(105)),
InputControl.LT to InputAction.MouseButton(273), InputControl.RT to InputAction.MouseButton(272),
InputControl.LB to InputAction.Keys(listOf(29)), InputControl.RB to InputAction.Keys(listOf(56)),
InputControl.SELECT to InputAction.Command(InputCommand.KEYBOARD),
InputControl.START to InputAction.Command(InputCommand.MENU),
InputControl.LEFT_PAD to InputAction.Scroll(), InputControl.RIGHT_PAD to InputAction.Pointer(),
InputControl.LEFT_CLICK to InputAction.MouseButton(273), InputControl.RIGHT_CLICK to InputAction.MouseButton(272),
)
return InputProfile(DESKTOP, InputRole.entries.associateWith { desktop })
}
}
}
internal fun defaultElementClick(element: InputSurfaceElement, action: InputAction, legacy: (InputControl) -> InputAction): InputAction {
fun click(control: InputControl) = when (control) {
InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3
InputControl.LEFT_PAD -> InputControl.LEFT_CLICK; InputControl.RIGHT_PAD -> InputControl.RIGHT_CLICK
else -> null
}
if (!element.hasClick) return InputAction.Disabled
if (element.id == "legacy:${element.control.id}") {
val target = click(element.control) ?: return InputAction.Disabled
return legacy(target).let { if (it is InputAction.Native && it.target == null) InputAction.Native(target) else it }
}
return (action as? InputAction.Native)?.let { click(it.target ?: element.control) }?.let { InputAction.Native(it) } ?: InputAction.Disabled
}
internal fun InputAction.validForClick(): Boolean = if (this is InputAction.Native)
target != null && target.dimensions == 1 && target != InputControl.LT && target != InputControl.RT
else validFor(InputControl.A)
internal fun InputAction.validFor(control: InputControl): Boolean = when (this) {
is InputAction.Native -> target == null || (control.isPad == target.isPad && control.dimensions == target.dimensions &&
(!control.isTrackpad && !target.isTrackpad || control.isTrackpad && target.isTrackpad))
is InputAction.Keys -> codes.isNotEmpty() && codes.size <= 8 && codes.distinct().size == codes.size && codes.all { it in 1..255 }
is InputAction.MouseButton -> code in 272..276
is InputAction.Directions -> control.dimensions == 2 && listOf(up, right, down, left).all { it in 0..255 } && (diagonals == null || diagonals.size == 4 && diagonals.all { it in 0..255 })
is InputAction.Pointer -> control.dimensions == 2 && speed.isFinite() && speed in 100f..3000f
is InputAction.Scroll -> control.dimensions == 2 && speed.isFinite() && speed in 1f..60f
else -> true
}
@@ -0,0 +1,139 @@
package com.droiddeck.launcher.input
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
object InputProfiles {
const val AUTO = "auto"
private const val VERSION = 1
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
fun selection(context: Context): String = InputLibraries.read(context).selected
fun select(context: Context, id: String) {
InputLibraries.update(context) { library ->
require(id == AUTO || library.profiles.any { it.id == id })
library.copy(selected = id, selections = emptyMap())
}
}
fun active(context: Context, desktop: Boolean): InputProfile {
val library = InputLibraries.read(context)
return library.compileActive(desktop)
}
fun read(context: Context, id: String): InputProfile {
return InputLibraries.read(context).compile(id)
}
internal fun readLegacy(context: Context, id: String): InputProfile {
migrate(context)
var result = InputProfile.defaults(id)
val stored = prefs(context).getString("profile.$id", null) ?: return result
val document = runCatching { JSONObject(stored) }.getOrNull() ?: return result
if (document.optInt("version") != VERSION) return result
val bindings = document.optJSONArray("bindings") ?: return result
for (i in 0 until minOf(bindings.length(), 256)) {
val entry = bindings.optJSONObject(i) ?: continue
val role = runCatching { InputRole.valueOf(entry.getString("role")) }.getOrNull() ?: continue
val control = InputControl.fromId(entry.optString("control")) ?: continue
val action = decode(entry.optJSONObject("action") ?: continue) ?: continue
if (action.validFor(control)) result = result.withBinding(role, control, action)
}
val keys = document.optJSONArray("keys")
if (keys != null) result = result.copy(keys = (0 until minOf(keys.length(), 64)).mapNotNull { i ->
runCatching {
val item = keys.getJSONObject(i)
val action = decode(item.getJSONObject("action")) ?: return@mapNotNull null
if (!action.validFor(InputControl.A) || action is InputAction.Native && action.target != null) return@mapNotNull null
val key = PhysicalInputKey(item.getString("device"), item.getString("name"), item.getInt("keyCode"), item.getInt("scanCode"))
if (key.device.isBlank() || key.keyCode < 0 || key.scanCode < 0) return@mapNotNull null
PhysicalInputBinding(key, action)
}.getOrNull()
}.distinctBy { Triple(it.key.device, it.key.keyCode, it.key.scanCode) })
return result
}
fun save(context: Context, profile: InputProfile) {
InputLibraries.update(context) { library ->
val definition = library.profile(profile.id)
var updated = library
for (role in InputRole.entries) {
val preset = updated.preset(definition.binding(role))
updated = updated.withPreset(preset.copy(controls = profile.bindings[role].orEmpty(),
elements = profile.elements[role].orEmpty(), keys = if (role == InputRole.PHYSICAL) profile.keys else emptyList(),
clicks = profile.clicks[role].orEmpty()))
}
updated
}
}
private fun saveLegacy(context: Context, profile: InputProfile) {
val bindings = JSONArray()
for ((role, controls) in profile.bindings) for ((control, action) in controls) {
require(action.validFor(control))
bindings.put(JSONObject().put("role", role.name).put("control", control.id).put("action", encode(action)))
}
val keys = JSONArray()
for (binding in profile.keys) keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", encode(binding.action)))
prefs(context).edit().putString("profile.${profile.id}", JSONObject()
.put("version", VERSION).put("id", profile.id).put("bindings", bindings).put("keys", keys).toString()).apply()
}
fun reset(context: Context, id: String) = InputLibraries.update(context) { it.resetProfile(id) }.let { }
fun resetAll(context: Context) {
prefs(context).edit().clear().putBoolean("migrated", true).apply()
InputSurfaceLayout.resetAll(context)
}
private fun migrate(context: Context) {
val p = prefs(context)
if (p.getBoolean("migrated", false)) return
var game = InputProfile.defaults(InputProfile.GAME)
val old = ControllerPrefs.read(context)
for ((id, target) in old.mapping) {
val source = InputControl.fromId(id) ?: continue
val action = if (target == ControllerPrefs.OFF) InputAction.Disabled
else InputAction.Native(InputControl.fromId(target) ?: continue)
if (target != id) game = game.withBinding(InputRole.PRIMARY, source, action)
}
if (old.selectSteam) game = game.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE))
if (!p.contains("profile.${InputProfile.GAME}")) saveLegacy(context, game)
if (old.selectSteam && !p.contains("profile.${InputProfile.DESKTOP}")) {
saveLegacy(context, InputProfile.defaults(InputProfile.DESKTOP)
.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE)))
}
p.edit().putBoolean("migrated", true).apply()
}
internal fun encode(action: InputAction): JSONObject = JSONObject().apply {
when (action) {
is InputAction.Native -> { put("type", "native"); action.target?.let { put("target", it.id) } }
InputAction.Disabled -> put("type", "disabled")
is InputAction.Keys -> { put("type", "keys"); put("codes", JSONArray(action.codes)) }
is InputAction.MouseButton -> { put("type", "button"); put("code", action.code) }
is InputAction.Command -> { put("type", "command"); put("command", action.command.name) }
is InputAction.Directions -> { put("type", "directions"); put("codes", JSONArray(listOf(action.up, action.right, action.down, action.left))); action.diagonals?.let { put("diagonals", JSONArray(it)) } }
is InputAction.Pointer -> { put("type", "pointer"); put("speed", action.speed.toDouble()) }
is InputAction.Scroll -> { put("type", "scroll"); put("speed", action.speed.toDouble()); put("horizontal", action.horizontal) }
}
}
internal fun decode(json: JSONObject): InputAction? = runCatching {
when (json.getString("type")) {
"native" -> InputAction.Native(if (json.has("target")) InputControl.fromId(json.getString("target")) ?: return null else null)
"disabled" -> InputAction.Disabled
"keys" -> InputAction.Keys(json.getJSONArray("codes").let { a -> (0 until a.length()).map { a.getInt(it) } })
"button" -> InputAction.MouseButton(json.getInt("code"))
"command" -> InputAction.Command(InputCommand.valueOf(json.getString("command")))
"directions" -> json.getJSONArray("codes").let { InputAction.Directions(it.getInt(0), it.getInt(1), it.getInt(2), it.getInt(3), json.optJSONArray("diagonals")?.let { d -> (0 until d.length()).map { d.getInt(it) } }) }
"pointer" -> InputAction.Pointer(json.getDouble("speed").toFloat())
"scroll" -> InputAction.Scroll(json.getDouble("speed").toFloat(), json.optBoolean("horizontal", false))
else -> null
}
}.getOrNull()
}
@@ -0,0 +1,361 @@
package com.droiddeck.launcher.input
import kotlin.math.abs
interface InputOutput {
fun key(code: Int, down: Boolean)
fun button(code: Int, down: Boolean)
fun pointer(dx: Float, dy: Float)
fun scroll(steps: Int)
fun scrollHorizontal(steps: Int) {}
fun command(command: InputCommand)
fun command(command: InputCommand, role: InputRole) = command(command)
fun requestMotion()
}
class InputRouter(private val output: InputOutput) {
private val keys = IntArray(256)
private val buttons = IntArray(5)
private val channels = ArrayList<Channel>()
private val pads = ArrayList<PadSource>()
private var profile = InputProfile.defaults(InputProfile.GAME)
private var moving = false
private var wheel = 0f
private var horizontalWheel = 0f
private var enabled = true
private val snapshots = ArrayList<() -> Unit>()
@Synchronized
fun beforeProfileChange(snapshot: () -> Unit) { snapshots.add(snapshot) }
@Synchronized
fun removeSnapshot(snapshot: () -> Unit) { snapshots.remove(snapshot) }
@Synchronized
fun setEnabled(on: Boolean): Boolean {
if (enabled == on) return false
if (!on) releaseAll()
enabled = on
for (pad in pads) pad.refreshIdentity()
return true
}
@Synchronized
fun setProfile(next: InputProfile) {
if (profile == next) return
for (snapshot in snapshots) snapshot()
for (pad in pads) pad.snapshot()
profile = next
for (channel in channels) channel.configure(channel.actionFrom(next))
for (pad in pads) pad.refreshIdentity()
wheel = 0f
horizontalWheel = 0f
moving = false
}
@JvmOverloads
@Synchronized
fun pad(role: InputRole, retained: PadState? = null): PadSource = PadSource(role, retained).also { pads.add(it) }
@Synchronized
fun channel(role: InputRole, control: InputControl, fixedAction: InputAction? = null, element: InputSurfaceElement? = null, elementClick: Boolean = false): Channel = Channel(role, control, fixedAction, element, elementClick).also {
channels.add(it)
it.configure(it.actionFrom(profile))
}
@Synchronized
fun releaseAll() {
for (snapshot in snapshots) snapshot()
for (pad in pads) pad.snapshot()
for (channel in channels) channel.suspend()
for (pad in pads) pad.refreshIdentity()
wheel = 0f
horizontalWheel = 0f
moving = false
}
@Synchronized
fun tick(seconds: Float): Boolean {
if (!enabled) return false
var x = 0f
var y = 0f
var scroll = 0f
var horizontalScroll = 0f
for (channel in channels) {
if (channel.blocked || channel.control.isTrackpad) continue
val cx = if (abs(channel.x) > 0.12f) channel.x else 0f
val cy = if (abs(channel.y) > 0.12f) channel.y else 0f
when (val action = channel.action) {
is InputAction.Pointer -> { x += cx * action.speed; y += cy * action.speed }
is InputAction.Scroll -> if (action.horizontal) horizontalScroll += cx * action.speed else scroll += cy * action.speed
else -> Unit
}
}
val dt = seconds.coerceIn(0f, 0.05f)
if (x != 0f || y != 0f) output.pointer(x * dt, y * dt)
wheel += scroll * dt
horizontalWheel += horizontalScroll * dt
flushWheel()
moving = x != 0f || y != 0f || scroll != 0f || horizontalScroll != 0f
return moving
}
private fun flushWheel() {
val steps = wheel.toInt()
if (steps != 0) { wheel -= steps; output.scroll(steps) }
val horizontalSteps = horizontalWheel.toInt()
if (horizontalSteps != 0) { horizontalWheel -= horizontalSteps; output.scrollHorizontal(horizontalSteps) }
}
private fun key(code: Int, down: Boolean) {
val was = keys[code]
keys[code] = (was + if (down) 1 else -1).coerceAtLeast(0)
if ((was == 0) != (keys[code] == 0)) output.key(code, down)
}
private fun button(code: Int, down: Boolean) {
val at = code - 272
val was = buttons[at]
buttons[at] = (was + if (down) 1 else -1).coerceAtLeast(0)
if ((was == 0) != (buttons[at] == 0)) output.button(code, down)
}
inner class Channel internal constructor(val role: InputRole, val control: InputControl, private val fixedAction: InputAction?, private val element: InputSurfaceElement?, private val elementClick: Boolean) {
var action: InputAction = InputAction.Native()
private set
internal var x = 0f
internal var y = 0f
internal var contact = false
internal var blocked = false
private var held = false
private val directions = BooleanArray(4)
private var radialKeys = emptyList<Int>()
internal fun actionFrom(profile: InputProfile) = element?.let { if (elementClick) profile.elementClickAction(role, it) else profile.elementAction(role, it.id, control) }
?: profile.action(role, control)
internal fun configure(next: InputAction) {
suspend()
action = (fixedAction ?: next).takeIf { if (elementClick) it.validForClick() else if (element != null) it.validFor(element) else it.validFor(control) }
?: if (element != null) InputAction.Disabled else InputAction.Native()
}
internal fun observe(value: Float, second: Float = 0f, touching: Boolean = false) {
x = value; y = second; contact = touching
}
internal fun awaitNeutral(value: Float, second: Float, touching: Boolean) = synchronized(this@InputRouter) {
observe(value, second, touching)
suspend()
}
internal fun nativeIdentity(): Boolean = enabled && !blocked && (action as? InputAction.Native)?.target == null && action is InputAction.Native
internal fun suspend() {
releaseOutputs()
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
}
fun release() = synchronized(this@InputRouter) {
releaseOutputs()
x = 0f; y = 0f; contact = false; blocked = false
}
fun close() = synchronized(this@InputRouter) {
release()
channels.remove(this)
}
fun acceptsPointerGesture() = synchronized(this@InputRouter) { enabled && !blocked && contact && action is InputAction.Pointer }
fun pointerDelta(dx: Float, dy: Float) = synchronized(this@InputRouter) {
val pointer = action as? InputAction.Pointer
if (enabled && !blocked && contact && pointer != null && dx.isFinite() && dy.isFinite()) output.pointer(dx * pointer.speed, dy * pointer.speed)
}
fun wheelSteps(steps: Int) = synchronized(this@InputRouter) {
val scroll = action as? InputAction.Scroll
if (enabled && !blocked && scroll != null && steps != 0) {
if (scroll.horizontal) output.scrollHorizontal(steps) else output.scroll(steps)
}
}
fun update(value: Float, second: Float = 0f, touching: Boolean = false): InputControl? = synchronized(this@InputRouter) {
val oldX = x
val oldY = y
val oldContact = contact
x = value.coerceIn(-1f, 1f)
y = second.coerceIn(-1f, 1f)
contact = touching
if (!enabled) {
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
return@synchronized null
}
if (blocked) {
if (!contact && abs(x) <= 0.12f && abs(y) <= 0.12f) blocked = false
return@synchronized null
}
when (val a = action) {
is InputAction.Native -> return@synchronized a.target ?: control
is InputAction.Disabled -> Unit
is InputAction.Pointer -> {
if (control.isTrackpad) {
if (contact && oldContact) output.pointer((x - oldX) * a.speed, -(y - oldY) * a.speed)
} else requestMotion()
}
is InputAction.Scroll -> {
if (control.isTrackpad) {
if (contact && oldContact) {
if (a.horizontal) horizontalWheel -= (x - oldX) * a.speed else wheel += (y - oldY) * a.speed
flushWheel()
}
} else requestMotion()
}
is InputAction.Directions -> {
val vertical = if (control.isTrackpad) -y else y
if (a.diagonals == null) {
direction(0, -vertical, a.up); direction(1, x, a.right)
direction(2, vertical, a.down); direction(3, -x, a.left)
} else {
val cardinal = listOf(a.up, a.right, a.down, a.left)
val sector = ((kotlin.math.round(Math.toDegrees(kotlin.math.atan2(vertical, x).toDouble()) / 45).toInt() + 2) % 8 + 8) % 8
val next = if (kotlin.math.hypot(x, vertical) < .24f) emptyList() else if (sector % 2 == 0) listOf(cardinal[sector / 2])
else a.diagonals[sector / 2].let { if (it != 0) listOf(it) else listOf(cardinal[sector / 2], cardinal[(sector / 2 + 1) % 4]) }
val codes = next.filter { it != 0 }.distinct()
radialKeys.filterNot { it in codes }.forEach { key(it, false) }
codes.filterNot { it in radialKeys }.forEach { key(it, true) }
radialKeys = codes
}
}
else -> {
val down = if (control.isTrackpad) contact else maxOf(abs(x), abs(y)) > if (held) 0.35f else 0.55f
if (held != down) {
held = down
when (a) {
is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) }
is InputAction.MouseButton -> button(a.code, down)
is InputAction.Command -> if (!down) output.command(a.command, role)
else -> Unit
}
}
}
}
null
}
private fun requestMotion() {
if (!moving && (abs(x) > 0.12f || abs(y) > 0.12f)) { moving = true; output.requestMotion() }
}
private fun direction(at: Int, value: Float, code: Int) {
val down = value > if (directions[at]) 0.35f else 0.55f
if (directions[at] != down) { directions[at] = down; if (code != 0) key(code, down) }
}
private fun releaseOutputs() {
when (val a = action) {
is InputAction.Keys -> if (held) a.codes.asReversed().forEach { key(it, false) }
is InputAction.MouseButton -> if (held) button(a.code, false)
is InputAction.Directions -> {
val codes = intArrayOf(a.up, a.right, a.down, a.left)
for (i in directions.indices) if (directions[i] && codes[i] != 0) key(codes[i], false)
}
else -> Unit
}
radialKeys.forEach { key(it, false) }; radialKeys = emptyList()
held = false
directions.fill(false)
}
}
inner class PadSource internal constructor(role: InputRole, retained: PadState?) {
private val routes = Array(InputControl.padControls.size) { channel(role, InputControl.padControls[it]) }
private val result = PadState()
private var last: PadState? = null
private var identity = true
private var nativeCandidate = true
init {
last = retained
if (retained != null) for (route in routes) {
route.awaitNeutral(retained.value(route.control), retained.secondValue(route.control), false)
}
refreshIdentity()
}
internal fun refreshIdentity() {
nativeCandidate = routes.all { (it.action as? InputAction.Native)?.target == null && it.action is InputAction.Native }
identity = enabled && nativeCandidate && routes.none { it.blocked }
}
internal fun snapshot() {
val raw = last ?: return
for (route in routes) {
route.x = raw.value(route.control)
route.y = raw.secondValue(route.control)
}
}
fun route(raw: PadState): PadState = synchronized(this@InputRouter) {
last = raw
if (identity) return@synchronized raw
result.clear()
for (route in routes) {
val x = raw.value(route.control)
val y = raw.secondValue(route.control)
val target = route.update(x, y) ?: continue
result.add(target, x, y)
}
if (nativeCandidate && enabled && routes.none { it.blocked }) identity = true
result
}
fun release() = synchronized(this@InputRouter) {
for (route in routes) route.release()
last = null
result.clear()
refreshIdentity()
}
fun close() = synchronized(this@InputRouter) {
release()
for (route in routes) route.close()
pads.remove(this)
}
}
}
private val padButtons = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB,
PadState.SELECT, PadState.START, PadState.L3, PadState.R3, PadState.GUIDE, PadState.QAM)
internal fun PadState.value(control: InputControl): Float = when (control) {
InputControl.UP -> if (up) 1f else 0f
InputControl.RIGHT -> if (right) 1f else 0f
InputControl.DOWN -> if (down) 1f else 0f
InputControl.LEFT -> if (left) 1f else 0f
InputControl.LT -> leftTrigger
InputControl.RT -> rightTrigger
InputControl.LS -> leftX
InputControl.RS -> rightX
else -> if (control.ordinal < padButtons.size && isDown(padButtons[control.ordinal])) 1f else 0f
}
internal fun PadState.secondValue(control: InputControl): Float = when (control) {
InputControl.LS -> leftY
InputControl.RS -> rightY
else -> 0f
}
internal fun PadState.add(control: InputControl, x: Float, y: Float = 0f) {
when (control) {
InputControl.UP -> up = up || x > 0.5f
InputControl.RIGHT -> right = right || x > 0.5f
InputControl.DOWN -> down = down || x > 0.5f
InputControl.LEFT -> left = left || x > 0.5f
InputControl.LT -> leftTrigger = maxOf(leftTrigger, x)
InputControl.RT -> rightTrigger = maxOf(rightTrigger, x)
InputControl.LS -> if (x * x + y * y > leftX * leftX + leftY * leftY) { leftX = x; leftY = y }
InputControl.RS -> if (x * x + y * y > rightX * rightX + rightY * rightY) { rightX = x; rightY = y }
else -> if (control.ordinal < padButtons.size && x > 0.5f) press(padButtons[control.ordinal], true)
}
}
@@ -0,0 +1,80 @@
package com.droiddeck.launcher.input
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
enum class DeckPanel(val id: String, val layout: String, val bindings: String, @StringRes val label: Int) {
GAMEPAD("gamepad", "builtin.deck.gamepad", "preset.deck.gamepad", R.string.deck_tab_gamepad),
GRIPS("grips", "builtin.deck.grips", "preset.deck.grips", R.string.deck_tab_grips),
LEFT("left", "builtin.deck.left", "preset.deck.left", R.string.deck_tab_left),
RIGHT("right", "builtin.deck.right", "preset.deck.right", R.string.deck_tab_right),
BOTH("both", InputLibraries.DECK, InputStockLayouts.DECK, R.string.deck_tab_both);
companion object {
fun from(id: String?) = entries.firstOrNull { it.id == id } ?: BOTH
}
}
internal object InputStockLayouts {
private val NEUTRAL = InputSurfaceStyle(tint = null)
const val DECK = "preset.deck"
const val TRACKPAD = "preset.trackpad"
private fun grips() = listOf(InputControl.L4, InputControl.L5, InputControl.R5, InputControl.R4).mapIndexed { i, c ->
InputSurfaceElement(c, .02f + i * .245f, .78f, .225f, .20f)
}
private fun pads() = listOf(InputSurfaceElement(InputControl.LEFT_PAD, .02f, .02f, .47f, .74f),
InputSurfaceElement(InputControl.RIGHT_PAD, .51f, .02f, .47f, .74f))
private fun gripGrid() = listOf(InputControl.L4 to (.02f to .02f), InputControl.L5 to (.02f to .51f),
InputControl.R4 to (.51f to .02f), InputControl.R5 to (.51f to .51f)).map { (c, at) ->
InputSurfaceElement(c, at.first, at.second, .47f, .47f)
}
private fun pad(right: Boolean) = listOf(
InputSurfaceElement(if (right) InputControl.RIGHT_PAD else InputControl.LEFT_PAD, .02f, .02f, .96f, .74f),
InputSurfaceElement(if (right) InputControl.RIGHT_CLICK else InputControl.LEFT_CLICK, .02f, .78f, .96f, .20f),
)
private fun gamepad(): List<InputSurfaceElement> {
fun round(c: InputControl, x: Float, y: Float, size: Float) = InputSurfaceElement(c, x, y, size, size, buttonShape = InputButtonShape.CIRCLE)
fun pill(c: InputControl, x: Float) = InputSurfaceElement(c, x, .02f, .2f, .12f, buttonShape = InputButtonShape.PILL)
return listOf(pill(InputControl.LT, .02f), pill(InputControl.LB, .24f), pill(InputControl.RB, .56f), pill(InputControl.RT, .78f),
InputSurfaceElement(InputControl.LS, .02f, .17f, .4f, .4f),
InputSurfaceElement(InputControl.RIGHT_PAD, .58f, .59f, .4f, .39f),
round(InputControl.UP, .14f, .6f, .12f), round(InputControl.LEFT, .03f, .73f, .12f),
round(InputControl.RIGHT, .25f, .73f, .12f), round(InputControl.DOWN, .14f, .86f, .12f),
round(InputControl.Y, .72f, .17f, .12f), round(InputControl.X, .61f, .3f, .12f),
round(InputControl.B, .83f, .3f, .12f), round(InputControl.A, .72f, .43f, .12f),
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(
InputSurfaceElement(InputControl.RIGHT_PAD, .02f, .02f, .96f, .96f, mouseGestures = true)), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.GAMEPAD.layout, "Deck gamepad", gamepad(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.GRIPS.layout, "Deck grips", gripGrid(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.LEFT.layout, "Deck left trackpad", pad(right = false), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(DeckPanel.RIGHT.layout, "Deck right trackpad", pad(right = true), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
)
fun actions(layout: SavedInputLayout): Map<String, InputAction> = (if (layout.id == InputLibraries.TRACKPAD) mapOf(
"legacy:right-pad" to InputAction.Pointer(), "legacy:a" to InputAction.MouseButton(272), "legacy:b" to InputAction.MouseButton(273),
) else emptyMap()).filterKeys { id -> layout.elements.any { it.id == id } }
fun clicks(layout: SavedInputLayout): Map<String, InputAction> = if (layout.id == InputLibraries.TRACKPAD)
mapOf("legacy:right-pad" to InputAction.MouseButton(272)) else emptyMap()
fun presets(layouts: List<SavedInputLayout>) = layouts.map { layout ->
val desktop = layout.id == InputLibraries.TRACKPAD
val id = if (desktop) TRACKPAD else DeckPanel.entries.first { it.layout == layout.id }.bindings
SavedInputBindings(id, layout.name, false, layout.id,
controls = InputProfile.defaults(if (desktop) InputProfile.DESKTOP else InputProfile.GAME).bindings[InputRole.SECONDARY].orEmpty(),
elements = actions(layout), clicks = clicks(layout))
}
}
@@ -0,0 +1,122 @@
package com.droiddeck.launcher.input
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
enum class InputElementKind(val control: InputControl) {
BUTTON(InputControl.A), STICK(InputControl.LS), TRACKPAD(InputControl.LEFT_PAD),
DPAD(InputControl.LS), TRIGGER(InputControl.LT);
companion object {
fun from(control: InputControl) = when {
control.isTrackpad -> TRACKPAD
control.dimensions == 2 -> STICK
control == InputControl.LT || control == InputControl.RT -> TRIGGER
else -> BUTTON
}
}
}
enum class InputButtonShape { ROUNDED, PILL, CIRCLE, RECTANGLE }
data class InputSurfaceElement(
val control: InputControl, val x: Float, val y: Float, val width: Float, val height: Float,
val id: String = "legacy:${control.id}",
val kind: InputElementKind = InputElementKind.from(control),
val name: String = control.label,
val floating: Boolean = true,
val hideWhenIdle: Boolean = false,
val mouseGestures: Boolean = false,
val toggle: Boolean = false,
val icon: Int = 0,
val image: String = "",
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
fun bounded() = copy(x = x.coerceIn(0f, 0.97f), y = y.coerceIn(0f, 0.97f),
width = width.coerceIn(0.03f, (1f - x.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)), height = height.coerceIn(0.03f, (1f - y.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)))
}
internal val QUICK_BUTTONS = listOf(InputCommand.STEAM, InputCommand.QUICK_ACCESS, InputCommand.KEYBOARD)
val InputSurfaceElement.quickButton: InputCommand? get() = when {
id == "legacy:${InputControl.GUIDE.id}" -> InputCommand.STEAM
id == "legacy:${InputControl.QAM.id}" -> InputCommand.QUICK_ACCESS
id == "builtin:keyboard" -> InputCommand.KEYBOARD
else -> null
}
internal fun List<InputSurfaceElement>.scaled(factor: Float): List<InputSurfaceElement> = map { e ->
fun anchor(centre: Float) = when { centre < 1f / 3f -> 0f; centre > 2f / 3f -> 1f; else -> .5f }
val w = (e.width * factor).coerceIn(.03f, 1f)
val h = (e.height * factor).coerceIn(.03f, 1f)
val cx = e.x + e.width / 2
val cy = e.y + e.height / 2
val ax = anchor(cx)
val ay = anchor(cy)
e.copy(x = (ax + (cx - ax) * factor - w / 2).coerceIn(0f, 1f - w), y = (ay + (cy - ay) * factor - h / 2).coerceIn(0f, 1f - h),
width = w, height = h).bounded()
}
internal fun List<InputSurfaceElement>.withPositionsOf(defaults: List<InputSurfaceElement>): List<InputSurfaceElement> {
val byId = defaults.associateBy { it.id }
return map { e -> byId[e.id]?.let { d -> e.copy(x = d.x, y = d.y, width = d.width, height = d.height).bounded() } ?: e }
}
object InputSurfaceLayout {
private fun prefs(context: Context) = context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE)
fun resetAll(context: Context) { prefs(context).edit().clear().apply() }
fun defaults() = listOf(
InputSurfaceElement(InputControl.LEFT_PAD, 0.02f, 0.02f, 0.46f, 0.65f),
InputSurfaceElement(InputControl.RIGHT_PAD, 0.52f, 0.02f, 0.46f, 0.65f),
InputSurfaceElement(InputControl.A, 0.02f, 0.72f, 0.20f, 0.25f),
InputSurfaceElement(InputControl.B, 0.26f, 0.72f, 0.20f, 0.25f),
InputSurfaceElement(InputControl.SELECT, 0.52f, 0.72f, 0.20f, 0.25f),
InputSurfaceElement(InputControl.START, 0.76f, 0.72f, 0.22f, 0.25f),
)
fun read(context: Context, profile: String, role: InputRole = InputRole.SECONDARY): List<InputSurfaceElement> =
InputLibraries.read(context).layout(profile, role)?.elements.orEmpty()
internal fun hasLegacy(context: Context, profile: String) = prefs(context).contains(profile)
internal fun readLegacy(context: Context, profile: String): List<InputSurfaceElement> {
val text = prefs(context).getString(profile, null) ?: return defaults()
return runCatching {
val doc = JSONObject(text)
require(doc.getInt("version") == 1)
val elements = doc.getJSONArray("elements")
(0 until minOf(elements.length(), 28)).mapNotNull { i ->
val item = elements.optJSONObject(i) ?: return@mapNotNull null
val control = InputControl.fromId(item.optString("control")) ?: return@mapNotNull null
val values = listOf("x", "y", "w", "h").map { item.optDouble(it, Double.NaN).toFloat() }
if (values.any { !it.isFinite() }) return@mapNotNull null
InputSurfaceElement(control, values[0], values[1], values[2], values[3]).bounded()
}.distinctBy { it.control }
}.getOrElse { defaults() }
}
fun save(context: Context, profile: String, layout: List<InputSurfaceElement>) {
InputLibraries.update(context) { library ->
val saved = library.layout(profile, InputRole.SECONDARY) ?: return@update library
library.withLayout(saved.copy(elements = layout.map { it.bounded() }))
}
}
internal fun saveLegacy(context: Context, profile: String, layout: List<InputSurfaceElement>) {
val array = JSONArray()
for (element in layout.distinctBy { it.control }.take(28)) {
val e = element.bounded()
array.put(JSONObject().put("control", e.control.id).put("x", e.x.toDouble()).put("y", e.y.toDouble())
.put("w", e.width.toDouble()).put("h", e.height.toDouble()))
}
prefs(context).edit().putString(profile, JSONObject().put("version", 1).put("elements", array).toString()).apply()
}
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.input
import kotlin.math.abs
internal data class SurfaceSnapResult(val element: InputSurfaceElement, val x: Float? = null, val y: Float? = null)
internal fun snapSurfaceElement(element: InputSurfaceElement, others: List<InputSurfaceElement>, thresholdX: Float, thresholdY: Float,
corner: Int = 0): SurfaceSnapResult {
fun axis(start: Float, size: Float, targets: List<Float>, threshold: Float, low: Boolean, high: Boolean): Triple<Float, Float, Float?> {
val anchors = when { low -> listOf(0f); high -> listOf(size); else -> listOf(0f, size / 2f, size) }
val candidate = anchors.flatMap { anchor -> targets.map { target -> Triple(target - start - anchor, target, anchor) } }
.filter { abs(it.first) <= threshold }.minByOrNull { abs(it.first) } ?: return Triple(start, size, null)
val offset = candidate.first
val nextStart = if (high) start else start + offset
val nextSize = when { low -> size - offset; high -> size + offset; else -> size }
if (nextStart < 0f || nextSize < .03f || nextStart + nextSize > 1.00001f) return Triple(start, size, null)
return Triple(nextStart.coerceAtLeast(0f), nextSize, candidate.second)
}
val xTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.x, it.x + it.width / 2f, it.x + it.width) }
val yTargets = listOf(0f, .5f, 1f) + others.flatMap { listOf(it.y, it.y + it.height / 2f, it.y + it.height) }
val x = axis(element.x, element.width, xTargets, thresholdX, corner and 1 != 0, corner and 2 != 0)
val y = axis(element.y, element.height, yTargets, thresholdY, corner and 4 != 0, corner and 8 != 0)
return SurfaceSnapResult(element.copy(x = x.first, y = y.first, width = x.second, height = y.second).bounded(), x.third, y.third)
}
@@ -0,0 +1,124 @@
package com.droiddeck.launcher.input
class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole, val element: InputSurfaceElement) {
private val router = pad.inputRouter
private val route = router?.channel(role, element.control, element = element)
private val deck = DeckControls.source(role, routed = true)
private val clickDeck = DeckControls.source(role, routed = true)
private val clickOwner = Any()
private val clickRoute = if (element.hasClick) router?.channel(role, InputControl.A, element = element, elementClick = true) else null
private val gestureWheel = if (element.mouseGestures) router?.channel(role, InputControl.RIGHT_PAD, fixedAction = InputAction.Scroll()) else null
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) {
if (closed) return
val timing = InputTiming.begin()
route?.pointerDelta(dx, dy)
InputTiming.end(timing)
}
fun gestureWheel(steps: Int) { if (!closed && route?.acceptsPointerGesture() == true) gestureWheel?.wheelSteps(steps) }
fun gestureButton(code: Int, down: Boolean) {
if (closed || down && route?.acceptsPointerGesture() != true) return
when (code) {
272 -> click(down)
273 -> gestureRight?.update(if (down) 1f else 0f)
274 -> gestureMiddle?.update(if (down) 1f else 0f)
}
}
private var nativePad = false
private var nativeDeck = false
private var clickPad = false
private var clickDeckActive = false
private var closed = false
private var fingerDown = false
private var latchBlocked = false
var latched = false
private set
var onStateChanged: () -> Unit = {}
private fun resetLatch() { latched = false; latchBlocked = fingerDown; onStateChanged() }
private val snapshot: () -> Unit = { releaseNative(); resetLatch(); if (element.toggle) route?.release() }
init { router?.beforeProfileChange(snapshot) }
fun update(value: Float, y: Float = 0f, touching: Boolean = false) {
if (closed) return
val timing = InputTiming.begin()
val x = if (element.toggle && route?.action !is InputAction.Command && route?.action !is InputAction.Disabled) {
val down = value > .5f
if (!down) latchBlocked = false
if (down && !fingerDown && !latchBlocked) latched = !latched
fingerDown = down
if (latched) 1f else 0f
} else value
val target = if (router == null) element.control else route?.update(x, y, touching)
val toPad = target?.isPad == true
val toDeck = target != null && !target.isPad
if (toPad || nativePad) writePad(this, target) { state ->
state.clear()
if (toPad) {
if (element.kind == InputElementKind.DPAD) {
state.up = y < -.5f; state.down = y > .5f; state.left = x < -.5f; state.right = x > .5f
} else state.add(target, x, y)
}
}
if (toDeck || nativeDeck) deck.native(if (toDeck) target else null, x, y, touching)
nativePad = toPad; nativeDeck = toDeck
if (element.toggle) onStateChanged()
InputTiming.end(timing)
}
fun cancel() {
if (closed) return
route?.takeIf { it.action is InputAction.Command }?.release()
update(0f)
}
fun click(down: Boolean) {
if (closed) return
val timing = InputTiming.begin()
val target = if (router == null) when (element.control) { InputControl.LS -> InputControl.L3; InputControl.RS -> InputControl.R3; else -> null } else clickRoute?.update(if (down) 1f else 0f)
val toPad = target?.isPad == true
val toDeck = target != null && !target.isPad
if (toPad || clickPad) writePad(clickOwner, target) { state ->
state.clear()
if (toPad && down) state.add(target, 1f)
}
if (toDeck || clickDeckActive) clickDeck.native(if (toDeck) target else null, if (down) 1f else 0f, 0f, false)
clickPad = toPad; clickDeckActive = toDeck
InputTiming.end(timing)
}
private fun writePad(owner: Any, target: InputControl?, mutation: (PadState) -> Unit) {
if (router != null) pad.applyRoutedTouch(owner, role, mutation)
else pad.applyTouch(owner, role, when (target) { InputControl.LS -> PadBridge.LEFT_STICK; InputControl.RS -> PadBridge.RIGHT_STICK; else -> 0 }, mutation)
}
private fun releaseNative() {
if (nativePad) pad.releaseTouch(this)
if (nativeDeck) deck.release()
if (clickPad) pad.releaseTouch(clickOwner)
if (clickDeckActive) clickDeck.release()
clickPad = false; clickDeckActive = false
nativePad = false; nativeDeck = false
}
fun close() {
if (closed) return
closed = true
router?.removeSnapshot(snapshot)
releaseNative(); resetLatch()
route?.close(); clickRoute?.close(); gestureWheel?.close(); gestureRight?.close(); gestureMiddle?.close(); deck.close(); clickDeck.close()
}
fun release() {
if (closed) return
releaseNative(); resetLatch()
route?.release(); clickRoute?.release(); gestureWheel?.release(); gestureRight?.release(); gestureMiddle?.release()
}
}
@@ -0,0 +1,33 @@
package com.droiddeck.launcher.input
import java.util.Locale
object InputTiming {
@JvmField @Volatile var enabled = false
private val samples = LongArray(4096)
private var count = 0L
private var next = 0
@JvmStatic fun begin(): Long = if (enabled) System.nanoTime() else 0L
@JvmStatic fun end(start: Long) {
if (start == 0L) return
val elapsed = System.nanoTime() - start
synchronized(this) {
samples[next] = elapsed
next = (next + 1) % samples.size
count++
}
}
@Synchronized fun reset() { count = 0; next = 0; samples.fill(0) }
@Synchronized fun report(): String {
val size = minOf(count, samples.size.toLong()).toInt()
if (size == 0) return "No input samples"
val values = samples.copyOf(size).sortedArray()
fun percentile(p: Double) = values[((size - 1) * p).toInt()] / 1000.0
return String.format(Locale.ROOT, "Dispatch to publication (µs): p50 %.1f, p95 %.1f, p99 %.1f; %d samples, last %d retained",
percentile(.5), percentile(.95), percentile(.99), count, size)
}
}
@@ -89,21 +89,31 @@ class KeyboardHost(
clearFocus()
}
fun type(text: CharSequence?) {
if (text.isNullOrEmpty()) return
val events = map.getEvents(text.toString().toCharArray())
if (events != null) {
for (event in events) sendKeyEvent(event)
} else {
for (ch in text) {
val t = System.currentTimeMillis()
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
sendKeyEvent(CharKeyEvent(down, ch))
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
}
}
}
fun tap(keyCode: Int) {
val t = System.currentTimeMillis()
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
}
private inner class KeyConnection : BaseInputConnection(this, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
if (text.isNullOrEmpty()) return true
val events = map.getEvents(text.toString().toCharArray())
if (events != null) {
for (event in events) sendKeyEvent(event)
} else {
for (ch in text) {
val t = System.currentTimeMillis()
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
sendKeyEvent(CharKeyEvent(down, ch))
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
}
}
type(text)
return true
}
@@ -123,12 +133,6 @@ class KeyboardHost(
return true
}
private fun tap(keyCode: Int) {
val t = System.currentTimeMillis()
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
}
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
override fun getSelectedText(flags: Int): CharSequence? = null
@@ -0,0 +1,352 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Rect
import kotlin.math.*
internal class LegacyGamepadLayout(private val context: Context, private val width: Int, private val height: Int, private val safe: Rect = Rect(),
private val scale: Float = 1f) {
private val resources get() = context.resources
private val settings = ControllerPrefs.read(context)
private var fit = 1f
private class Control(val id: String, val group: String, val stick: Int, val radiusDp: Float, val offsetXDp: Float, val offsetYDp: Float) {
var radius = 0f
var cx = 0f
var cy = 0f
val halfW get() = if (id in shoulderIds) radius * SHOULDER_WIDTH else radius
val halfH get() = if (id in shoulderIds) radius * SHOULDER_HEIGHT else radius
}
private val controls = listOf(
Control("up", "dpad", -1, 30f, 0f, -50f),
Control("right", "dpad", -1, 30f, 50f, 0f),
Control("down", "dpad", -1, 30f, 0f, 50f),
Control("left", "dpad", -1, 30f, -50f, 0f),
Control("ls", "ls", 0, 48f, 0f, 0f),
Control("rs", "rs", 1, 48f, 0f, 0f),
Control("a", "face", -1, 30f, 0f, 50f),
Control("b", "face", -1, 30f, 50f, 0f),
Control("x", "face", -1, 30f, -50f, 0f),
Control("y", "face", -1, 30f, 0f, -50f),
Control("lb", "lb", -1, 24f, 0f, 0f),
Control("rb", "rb", -1, 24f, 0f, 0f),
Control("lt", "lt", -1, 24f, 0f, 0f),
Control("rt", "rt", -1, 24f, 0f, 0f),
Control("select", "select", -1, 20f, 0f, 0f),
Control("start", "start", -1, 20f, 0f, 0f),
Control("guide", "guide", -1, 22f, 0f, 0f),
Control("qam", "qam", -1, 22f, 0f, 0f),
)
private val groups = controls.map { it.group }.distinct()
private fun dp(value: Float) = value * resources.displayMetrics.density
private fun scaled(value: Float) = dp(value) * settings.size / 100f * scale * fit
private fun fitScale(): Float {
val metrics = resources.displayMetrics
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
}
private fun pxPerMm(dpi: Float): Float {
val nominal = resources.displayMetrics.densityDpi.toFloat()
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
}
private fun placeAuto(w: Float, h: Float) {
val metrics = resources.displayMetrics
val mmX = pxPerMm(metrics.xdpi)
val mmY = pxPerMm(metrics.ydpi)
val usableW = w - safe.left - safe.right
val usableH = h - safe.top - safe.bottom
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
val gap = dp(12f)
val floor = BOTTOM_MARGIN_MM * mmY
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
put("ls", safe.left + inboard, restY)
put("face", w - safe.right - inboard, restY)
besideBelow("dpad", "ls", 1f, gap, floor)
besideBelow("rs", "face", -1f, gap, floor)
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
val shoulderY = if (phone) safe.top + floor + halfH("lb")
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
val bottom = h - safe.bottom - floor - extent("guide")
val spread = extent("guide") + gap / 2f
put("guide", w / 2f - spread, bottom)
put("qam", w / 2f + spread, bottom)
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
put("select", inner, restY)
put("start", w - inner, restY)
if (phone) {
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
put("select", top, shoulderY)
put("start", w - top, shoulderY)
}
if (crowded("select", gap) || crowded("start", gap)) {
above("select", "dpad", gap)
val (sx, sy) = centre("select")
put("start", w - sx, sy)
if (crowded("start", gap)) above("start", "rs", gap)
}
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val row = centre("select").second
put("guide", w / 2f - spread, row)
put("qam", w / 2f + spread, row)
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val top = safe.top + dp(8f) + extent("guide")
put("guide", w / 2f - spread, top)
put("qam", w / 2f + spread, top)
}
}
}
private fun drop(group: String, anchor: String, gap: Float): Float =
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
val (ax, ay) = centre(anchor)
val reach = extent(anchor) + extent(group) + gap
val floor = height - safe.bottom - margin - extent(group)
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
val dx = sqrt(max(0f, reach * reach - dy * dy))
put(group, ax + inward * dx, ay + dy)
}
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
val (ax, _) = centre(anchor)
val spread = extent(bumper) + gap / 2f
put(bumper, ax + inward * spread, y)
put(trigger, ax - inward * spread, y)
}
private fun above(group: String, anchor: String, gap: Float) {
val (ax, ay) = centre(anchor)
put(group, ax, ay - extent(anchor) - gap - extent(group))
}
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
val (x, y) = centre(group)
return groups.any { other ->
if (other == group || other == except) return@any false
val (ox, oy) = centre(other)
val dx = x - ox
val dy = y - oy
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
}
}
private fun members(group: String) = controls.filter { it.group == group }
private fun centre(group: String): Pair<Float, Float> {
val first = members(group).first()
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
}
private fun extent(group: String): Float = members(group).maxOf { control ->
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
}
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
private fun put(group: String, x: Float, y: Float) {
for (control in members(group)) {
control.cx = x + scaled(control.offsetXDp)
control.cy = y + scaled(control.offsetYDp)
}
}
private fun clamp(group: String) {
val list = members(group)
val margin = dp(4f)
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
if (dx == 0f && dy == 0f) return
for (control in list) { control.cx += dx; control.cy += dy }
}
fun elements(): List<InputSurfaceElement> {
fit = fitScale()
controls.forEach { it.radius = scaled(it.radiusDp) }
placeAuto(width.toFloat(), height.toFloat())
for ((group, position) in ControllerPrefs.layout(context, width, height)) {
if (group in groups && position.first.isFinite() && position.second.isFinite()) put(group, position.first * width, position.second * height)
}
groups.forEach(::clamp)
val result = controls.filter {
settings.mapping[it.id] != ControllerPrefs.OFF && (it.id != "guide" || settings.steamButton) && (it.id != "qam" || settings.qamButton)
}.map { c ->
InputSurfaceElement(requireNotNull(InputControl.fromId(c.id)), (c.cx - c.halfW) / width, (c.cy - c.halfH) / height,
2 * c.halfW / width, 2 * c.halfH / height, floating = settings.adaptiveSticks, hideWhenIdle = c.stick >= 0 && settings.adaptiveSticks,
buttonShape = if (c.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE).let(::widenStick).bounded()
}.toMutableList()
if (settings.keyboardButton) result += keyboard()
return result
}
fun regenerate(stored: List<InputSurfaceElement>): List<InputSurfaceElement> = withGeometry(stored, elements())
fun rescale(stored: List<InputSurfaceElement>, from: LegacyGamepadLayout): List<InputSurfaceElement> {
val before = from.elements().associateBy { it.id }
val after = elements().associateBy { it.id }
return stored.map { e ->
val b = before[e.id] ?: return@map e
val a = after[e.id] ?: return@map e
val w = (a.width * e.width / b.width).coerceIn(.03f, 1f)
val h = (a.height * e.height / b.height).coerceIn(.03f, 1f)
val cx = a.x + a.width / 2 + (e.x + e.width / 2 - b.x - b.width / 2)
val cy = a.y + a.height / 2 + (e.y + e.height / 2 - b.y - b.height / 2)
e.copy(x = (cx - w / 2).coerceIn(0f, 1f - w), y = (cy - h / 2).coerceIn(0f, 1f - h), width = w, height = h).bounded()
}
}
fun buttonsOnly(stored: List<InputSurfaceElement>): List<InputSurfaceElement> {
val inset = dp(BUTTONS_ONLY_INSET_DP)
val bottom = height - safe.bottom - inset
val radius = dp(BUTTONS_ONLY_RADIUS_DP)
fun pick(control: InputControl, shown: Boolean) = stored.firstOrNull { it.id == "legacy:${control.id}" }
?: stored.firstOrNull { it.control == control && it.kind == InputElementKind.BUTTON }
?: InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE).takeIf { shown }
return listOfNotNull(
pick(InputControl.GUIDE, settings.steamButton)?.let { circle(it, safe.left + inset, bottom, radius) },
pick(InputControl.QAM, settings.qamButton)?.let { circle(it, width - safe.right - inset, bottom, radius) },
stored.firstOrNull { it.id == KEYBOARD_ID }?.let { circle(it, width / 2f, bottom, dp(KEYBOARD_RADIUS_DP)) },
)
}
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) {
val b = boxes[slot] ?: return
placed += InputSurfaceElement(control, b.left / width, b.top / height, b.width / width, b.height / height,
floating = false, buttonShape = shape).bounded()
}
fun cross(controls: List<InputControl>, slot: GamepadGeometry.Slot) {
val b = boxes[slot] ?: return
val side = min(b.width, b.height)
val spread = side * CROSS_SPREAD
val radius = side * CROSS_RADIUS
controls.zip(listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)).forEach { (control, direction) ->
placed += circle(InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE),
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
}
}
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
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)
box(InputControl.RB, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER, InputButtonShape.PILL)
box(InputControl.RT, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER, InputButtonShape.PILL)
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) }
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) =
e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height).bounded()
private fun withGeometry(stored: List<InputSurfaceElement>, placed: List<InputSurfaceElement>): List<InputSurfaceElement> {
val byId = placed.associateBy { it.id }
return stored.mapNotNull { e -> byId[e.id]?.let { e.copy(x = it.x, y = it.y, width = it.width, height = it.height) } }
}
private fun keyboard(): InputSurfaceElement {
val radius = dp(KEYBOARD_RADIUS_DP)
val cx = safe.left + dp(KEYBOARD_X_DP)
val cy = height - safe.bottom - dp(KEYBOARD_Y_DP)
return InputSurfaceElement(InputControl.A, (cx - radius) / width, (cy - radius) / height, 2 * radius / width, 2 * radius / height,
KEYBOARD_ID, name = "Keyboard", icon = InputButtonIcons.KEYBOARD, buttonShape = InputButtonShape.CIRCLE).bounded()
}
fun restyle(elements: List<InputSurfaceElement>): List<InputSurfaceElement> {
val dpad = elements.filter { it.id == "legacy:${it.control.id}" && it.control in DPAD && it.buttonShape == InputButtonShape.ROUNDED }
val centreX = dpad.map { (it.x + it.width / 2f) * width }.average().toFloat()
val centreY = dpad.map { (it.y + it.height / 2f) * height }.average().toFloat()
val resize = untouchedDpad(dpad, centreX, centreY)
return elements.map { e ->
when {
e.id == KEYBOARD_ID && e.icon == 0 && e.name == "Keyboard" && e.buttonShape == InputButtonShape.ROUNDED &&
e.x == .02f && e.y == .9f && e.width == .12f && e.height == .08f -> keyboard()
e.id == "legacy:${e.control.id}" && e.buttonShape == InputButtonShape.ROUNDED -> {
val round = e.copy(buttonShape = if (e.control.id in shoulderIds) InputButtonShape.PILL else InputButtonShape.CIRCLE)
(if (resize && e.control in DPAD) resizeDpad(round, centreX, centreY) else widenStick(round)).bounded()
}
else -> e
}
}
}
private fun untouchedDpad(dpad: List<InputSurfaceElement>, centreX: Float, centreY: Float): Boolean {
if (dpad.size != DPAD.size) return false
val radius = dpad[0].width * width / 2f
val tolerance = 1.5f
val offset = radius * LEGACY_DPAD_OFFSET_DP / LEGACY_DPAD_RADIUS_DP
return dpad.all { e ->
val old = controls.first { it.id == e.control.id }
val dx = (e.x + e.width / 2f) * width - centreX
val dy = (e.y + e.height / 2f) * height - centreY
abs(e.width * width / 2f - radius) <= tolerance && abs(e.height * height / 2f - radius) <= tolerance &&
abs(dx - offset * sign(old.offsetXDp)) <= tolerance && abs(dy - offset * sign(old.offsetYDp)) <= tolerance
}
}
private fun resizeDpad(e: InputSurfaceElement, centreX: Float, centreY: Float): InputSurfaceElement {
val old = controls.first { it.id == e.control.id }
val radius = e.width * width / 2f * old.radiusDp / LEGACY_DPAD_RADIUS_DP
val cx = centreX + ((e.x + e.width / 2f) * width - centreX) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
val cy = centreY + ((e.y + e.height / 2f) * height - centreY) * abs(old.offsetXDp + old.offsetYDp) / LEGACY_DPAD_OFFSET_DP
return e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height)
}
private fun widenStick(e: InputSurfaceElement): InputSurfaceElement {
val extra = e.width * (STICK_REACH - 1f)
return when (e.control) {
InputControl.LS -> (e.x - extra).coerceAtLeast(0f).let { x -> e.copy(x = x, width = e.x + e.width - x) }
InputControl.RS -> e.copy(width = (e.width + extra).coerceAtMost(1f - e.x))
else -> e
}
}
private companion object {
const val MIN_FACE_MM = 9.5f
const val FULL_SIZE_HEIGHT_MM = 85f
const val BOTTOM_MARGIN_MM = 5f
const val DROP_DEGREES = 35.0
const val PHONE_MAX_HEIGHT_MM = 90f
const val SHOULDER_WIDTH = 1.45f
const val SHOULDER_HEIGHT = 0.8f
val shoulderIds = setOf("lb", "rb", "lt", "rt")
const val STICK_REACH = 1.25f
const val LEGACY_DPAD_RADIUS_DP = 26f
const val LEGACY_DPAD_OFFSET_DP = 52f
val DPAD = setOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
const val KEYBOARD_ID = "builtin:keyboard"
const val KEYBOARD_RADIUS_DP = 18f
const val KEYBOARD_X_DP = 90f
const val KEYBOARD_Y_DP = 34f
const val BUTTONS_ONLY_RADIUS_DP = 30f
const val BUTTONS_ONLY_INSET_DP = 44f
const val CROSS_SPREAD = .30f
const val CROSS_RADIUS = .16f
val DPAD_ORDER = listOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT)
val FACE_ORDER = listOf(InputControl.Y, InputControl.B, InputControl.A, InputControl.X)
val GRIPS = listOf(InputControl.L4 to GamepadGeometry.Slot.L4, InputControl.L5 to GamepadGeometry.Slot.L5,
InputControl.R5 to GamepadGeometry.Slot.R5, InputControl.R4 to GamepadGeometry.Slot.R4)
}
}
@@ -0,0 +1,130 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.display.DisplayManager
import android.util.DisplayMetrics
import android.view.Display
import org.json.JSONObject
internal object LegacyScreenKeyMigration {
const val BACKUP = "legacy-screen-keys-backup"
const val MERGED = "legacy-screen-keys-merged"
private const val SHOWN = "migrated.screenkeys.0"
fun snapshot(context: Context): String? {
val values = ControllerPrefs.prefs(context).all.filterKeys { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }
return values.takeIf { it.isNotEmpty() }?.let { JSONObject(it).toString() }
}
fun clean(context: Context) {
val prefs = ControllerPrefs.prefs(context)
prefs.edit().apply { prefs.all.keys.filter { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") }.forEach(::remove) }.apply()
}
@Suppress("DEPRECATION")
fun migrate(context: Context, library: InputLibrary, raw: String): InputLibrary {
val old = JSONObject(raw)
val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
val metrics = DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) }
val settings = ControllerPrefs.read(context)
val style = InputSurfaceStyle(settings.tint, settings.opacity)
val sets = buildList {
old.optString("screenKeys").takeIf { it.isNotBlank() }?.let { add("Screen keys (imported)" to it) }
old.keys().asSequence().filter { it.startsWith("screenLayout.") }.sorted().forEach { key ->
add((key.removePrefix("screenLayout.").ifBlank { "Screen keys" } + " (imported)").take(80) to old.getString(key))
}
}
var result = library
for ((index, set) in sets.withIndex()) {
val id = "migrated.screenkeys.$index"
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.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)
val profile = SavedInputProfile(id, set.first, base.base, base.parts + (InputRole.PRIMARY to InputPart(bindings.id, layout.id)))
result = result.withLayout(layout).withPreset(bindings).withProfile(profile)
}
return result.validate()
}
fun merge(library: InputLibrary, raw: String): InputLibrary {
val old = JSONObject(raw)
if (!old.optBoolean("screenKeysShown", true) || old.optString("screenKeys").isBlank()) return library
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" } ?: return library
val actions = library.bindings.firstOrNull { it.id == "$SHOWN.bindings" }?.elements.orEmpty()
var result = library
for (part in listOf(false, true).map { library.activePart(it, InputRole.PRIMARY) }.distinct()) {
val layout = result.layouts.firstOrNull { it.id == part.layout } ?: continue
val preset = result.preset(part.bindings)
if (!layout.editable || preset.layoutId != layout.id || layout.elements.any { e -> keys.elements.any { it.id == e.id } } ||
layout.elements.size + keys.elements.size > 64) continue
val added = keys.elements.mapNotNull { e -> actions[e.id]?.let { e.id to it } }.toMap()
result = result.withLayout(layout.copy(elements = layout.elements + keys.elements))
.withPreset(preset.copy(elements = added + preset.elements))
}
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)
internal fun parse(raw: String): List<LegacyScreenKey> = raw.split(';').filter { it.isNotBlank() }.map { entry ->
val parts = entry.split(':')
require(parts.size >= 2) { "Invalid legacy screen key" }
val codes = parts[0].split('+').map { it.toInt() }
val position = parts[1].split(',').map { it.toFloat() }
require(position.size == 3)
fun text(at: Int) = parts.getOrNull(at).orEmpty().replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
LegacyScreenKey(codes, position[0], position[1], position[2], text(2), text(3), text(4),
parts.getOrNull(5)?.toFloatOrNull() ?: 1f, parts.getOrNull(6)?.toIntOrNull() ?: 0, parts.getOrNull(7) == "1", parts.getOrNull(8) != "0")
}
internal fun convert(context: Context, keys: List<LegacyScreenKey>, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
keys.mapIndexed { index, key ->
val codes = key.codes
require(codes.all { it in 0..255 })
require(listOf(key.x, key.y, key.size).all { it.isFinite() })
val shape = key.shape
val kind = when (shape) { 5 -> InputElementKind.DPAD; 6 -> InputElementKind.STICK; 4 -> InputElementKind.TRIGGER; else -> InputElementKind.BUTTON }
val label = key.text.ifEmpty { key.glyph }.ifEmpty { codes.filter { it != 0 }.joinToString("+") { pcKeyLabel(context, it) } }.ifBlank { "Button" }.take(80)
val image = key.image
val icon = codes.singleOrNull()?.takeIf { InputButtonIcons.has(it) && key.text.isEmpty() && key.glyph.isEmpty() } ?: 0
val heightPx = key.size.coerceIn(24f, 140f) * density
val ratio = when { shape == 2 -> 1f; shape == 4 -> 1.7f; shape == 5 || shape == 6 || image.isNotEmpty() || icon != 0 -> 1.15f; else -> (.55f * label.length + 1.05f).coerceIn(1.7f, 4.6f) }
val w = (heightPx * ratio / width.coerceAtLeast(1)).coerceIn(.03f, 1f)
val h = (heightPx / height.coerceAtLeast(1)).coerceIn(.03f, 1f)
val element = InputSurfaceElement(kind.control, (key.x - w / 2).coerceIn(0f, 1f - w), (key.y - h / 2).coerceIn(0f, 1f - h), w, h,
id = "imported:$index", kind = kind, name = label, floating = false,
toggle = key.toggle && kind != InputElementKind.DPAD && kind != InputElementKind.STICK,
icon = icon, image = image, opacity = key.opacity.coerceIn(.2f, 1f),
buttonShape = when (shape) { 0 -> InputButtonShape.PILL; 2 -> InputButtonShape.CIRCLE; 3 -> InputButtonShape.RECTANGLE; else -> InputButtonShape.ROUNDED },
showLabel = key.labels).bounded()
val action = when (kind) {
InputElementKind.DPAD -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(1) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(3) { 0 })
InputElementKind.STICK -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(4) { 0 }, codes.getOrElse(6) { 0 }, listOf(1, 3, 5, 7).map { codes.getOrElse(it) { 0 } })
else -> codes.filter { it != 0 }.distinct().let { if (it.isEmpty()) InputAction.Disabled else InputAction.Keys(it) }
}
require(action.validFor(element))
element to action
}
}
internal data class LegacyScreenKey(
val codes: List<Int>, val x: Float, val y: Float, val size: Float,
val glyph: String = "", val text: String = "", val image: String = "",
val opacity: Float = 1f, val shape: Int = 0, val toggle: Boolean = false, val labels: Boolean = true,
)
@@ -3,809 +3,181 @@ package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
import android.widget.FrameLayout
import com.droiddeck.launcher.ui.InputSurfaceView
@SuppressLint("ViewConstructor")
class OnScreenControls(
context: Context,
private val pad: PadBridge?,
private val editing: Boolean = false,
private val onKeyboard: (() -> Unit)? = null,
) : View(context) {
class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayout.Dock? = null) : FrameLayout(context) {
private val surface = InputSurfaceView(context, pad, inputRole = InputRole.PRIMARY, haptics = dock != null)
private var layout: SavedInputLayout? = null
private val safe = Rect()
private var viewWidth = 0
private var viewHeight = 0
private val placements = object : LinkedHashMap<Placement, List<InputSurfaceElement>>(4, .75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Placement, List<InputSurfaceElement>>) = size > PLACEMENT_CACHE
}
private val quickToggle = QuickToggle(context)
var isButtonsOnly = false
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 }
private class Control(
val id: String,
val group: String,
val stick: Int,
val radiusDp: Float,
val offsetXDp: Float,
val offsetYDp: Float,
) {
var target = id
var radius = 0f
var cx = 0f
var cy = 0f
var pressedBy = -1
var kx = 0f
var ky = 0f
var ax = 0f
var ay = 0f
var dirty = false
var clicked = false
var lastUp = 0L
val wide = id in shoulderIds
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
init {
addView(surface, LayoutParams(-1, -1))
val size = (QUICK_HIT_DP * 2 * resources.displayMetrics.density).toInt()
addView(quickToggle, LayoutParams(size, size, Gravity.START or Gravity.BOTTOM))
}
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
val dx = x - cx
val dy = y - cy
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
val hit = r * 1.25f + padding
return dx * dx + dy * dy <= hit * hit
fun updateLayout(value: SavedInputLayout?) {
if (layout == value) return
releaseAll()
layout = value
render()
}
fun setButtonsOnly(enabled: Boolean) {
if (isButtonsOnly == enabled) return
releaseAll()
isButtonsOnly = enabled
isQuickHidden = false
render()
}
fun setQuickHidden(hidden: Boolean) {
if (isButtonsOnly || isQuickHidden == hidden) return
releaseAll()
isQuickHidden = hidden
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> {
val key = Placement(layout, viewWidth, viewHeight, Rect(safe), isButtonsOnly)
return placements.getOrPut(key) {
OnScreenLayout.elements(context, layout, viewWidth, viewHeight, safe, isButtonsOnly, dock)
}
}
private fun render() {
val elements = placed()
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 || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.invalidate()
}
fun takesTouchAt(x: Float, y: Float) = surface.takesTouchAt(x, y) ||
quickToggle.visibility == VISIBLE && x >= quickToggle.left && x < quickToggle.right && y >= quickToggle.top && y < quickToggle.bottom
fun releaseAll() = surface.releaseInputs()
fun reload() { releaseAll(); placements.clear(); render() }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
if (w != viewWidth || h != viewHeight) {
viewWidth = w
viewHeight = h
render()
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
override fun onDetachedFromWindow() { releaseAll(); super.onDetachedFromWindow() }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
val offset = ((QUICK_CENTER_DP - QUICK_HIT_DP) * resources.displayMetrics.density).toInt()
val params = quickToggle.layoutParams as LayoutParams
val left = (cutout?.safeInsetLeft ?: 0) + offset
val bottom = (cutout?.safeInsetBottom ?: 0) + offset
if (params.leftMargin != left || params.bottomMargin != bottom) {
params.leftMargin = left
params.bottomMargin = bottom
quickToggle.layoutParams = params
}
val fresh = Rect(cutout?.safeInsetLeft ?: 0, if (dock == null) cutout?.safeInsetTop ?: 0 else 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
if (fresh != safe) {
safe.set(fresh)
render()
}
return super.onApplyWindowInsets(insets)
}
private inner class QuickToggle(context: Context) : View(context) {
var tint: Int? = ControllerPrefs.STEAM_BLUE
private val density = resources.displayMetrics.density
private var armed = false
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1.5f * density }
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textSize = 22 * density
}
fun drag(x: Float, y: Float): Boolean {
var dx = x - ax
var dy = y - ay
val d = sqrt(dx * dx + dy * dy)
val atLimit = d > radius
if (atLimit) { dx = dx / d * radius; dy = dy / d * radius }
if (!atLimit && abs(dx - kx) < MIN_STICK_MOVE_PX && abs(dy - ky) < MIN_STICK_MOVE_PX) return false
kx = dx; ky = dy; dirty = true
override fun onDraw(canvas: Canvas) {
val x = width / 2f
val y = height / 2f
val radius = QUICK_RADIUS_DP * density
val color = tint
fill.color = if (color != null) InputSurfaceView.shade(color, 190, 0.12f) else (InputSurfaceView.SURFACE and 0x00ffffff) or (190 shl 24)
canvas.drawCircle(x, y, radius, fill)
stroke.color = if (color != null) InputSurfaceView.light(color, 150, 0.25f) else InputSurfaceView.LINE
canvas.drawCircle(x, y, radius, stroke)
text.color = if (color != null) InputSurfaceView.light(color, 210, 0.75f) else InputSurfaceView.TEXT
canvas.drawText(if (isQuickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> armed = true
MotionEvent.ACTION_CANCEL -> armed = false
MotionEvent.ACTION_UP -> {
if (armed && event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
setQuickHidden(!isQuickHidden)
}
armed = false
}
}
return true
}
}
private val stickRedraw = ThrottledRedraw(this, STICK_REDRAW_MS)
private val controls = listOf(
Control("up", "dpad", -1, 26f, 0f, -52f),
Control("right", "dpad", -1, 26f, 52f, 0f),
Control("down", "dpad", -1, 26f, 0f, 52f),
Control("left", "dpad", -1, 26f, -52f, 0f),
Control("ls", "ls", 0, 48f, 0f, 0f),
Control("rs", "rs", 1, 48f, 0f, 0f),
Control("a", "face", -1, 30f, 0f, 50f),
Control("b", "face", -1, 30f, 50f, 0f),
Control("x", "face", -1, 30f, -50f, 0f),
Control("y", "face", -1, 30f, 0f, -50f),
Control("lb", "lb", -1, 24f, 0f, 0f),
Control("rb", "rb", -1, 24f, 0f, 0f),
Control("lt", "lt", -1, 24f, 0f, 0f),
Control("rt", "rt", -1, 24f, 0f, 0f),
Control("select", "select", -1, 20f, 0f, 0f),
Control("start", "start", -1, 20f, 0f, 0f),
Control("guide", "guide", -1, 22f, 0f, 0f),
Control("qam", "qam", -1, 22f, 0f, 0f),
)
private val groups = controls.map { it.group }.distinct()
private var buttonsOnly = false
val isButtonsOnly: Boolean get() = buttonsOnly
private var quickHidden = false
private var quickPressedBy = -1
private var keyboardPressedBy = -1
private var forwardedPointerBits = 0
var onTouchMiss: ((MotionEvent) -> Unit)? = null
private var settings = ControllerPrefs.read(context)
private var snap = settings.snap
private var safe = Rect()
private var selected: String? = null
private var snapX: Float? = null
private var snapY: Float? = null
private var grabX = 0f
private var grabY = 0f
private var ignoreSaved = false
private var fit = 1f
private var idleFill = 0
private var heldFill = 0
private var idleStroke = 0
private var heldStroke = 0
private var idleText = 0
private var stickFill = 0
private var knobFill = 0
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val arrow = Path()
private val box = RectF()
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
init {
applySettings()
}
private fun dp(value: Float) = value * resources.displayMetrics.density
private fun scaled(value: Float) = dp(value) * settings.size / 100f * fit
private fun fitScale(): Float {
val metrics = resources.displayMetrics
val heightMm = (height - safe.top - safe.bottom) / pxPerMm(metrics.ydpi)
val faceMm = dp(60f) / pxPerMm(metrics.xdpi)
val floor = (MIN_FACE_MM / faceMm).coerceAtMost(1f)
return (heightMm / FULL_SIZE_HEIGHT_MM).coerceIn(floor, 1f)
}
fun reload() {
releaseAll()
settings = ControllerPrefs.read(context)
setSnap(settings.snap)
applySettings()
relayout()
}
private fun clearSnapGuides() {
if (snapX == null && snapY == null) return
snapX = null
snapY = null
invalidate()
}
private fun applySettings() {
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
val tint = settings.tint
val alpha = settings.opacity / 100f
fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
fun light(a: Int, f: Float) = Color.argb(
(a * alpha).toInt(),
(Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
(Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
)
idleFill = shade(80, 0.12f)
heldFill = shade(160, 1f)
idleStroke = light(150, 0.25f)
heldStroke = light(230, 0.7f)
idleText = light(210, 0.75f)
stickFill = shade(60, 0.12f)
knobFill = shade(120, 0.35f)
stroke.strokeWidth = dp(1.5f)
}
fun setButtonsOnly(enabled: Boolean) {
if (buttonsOnly == enabled) return
releaseAll()
buttonsOnly = enabled
quickHidden = false
relayout()
}
fun setQuickHidden(hidden: Boolean) {
if (buttonsOnly || editing || quickHidden == hidden) return
if (hidden) releaseAll()
quickHidden = hidden
invalidate()
}
fun setSnap(on: Boolean) {
snap = on
if (!on) clearSnapGuides()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
relayout()
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
val fresh = Rect(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0)
if (fresh != safe) { safe = fresh; relayout() }
return super.onApplyWindowInsets(insets)
}
override fun onDetachedFromWindow() {
stickRedraw.cancel()
releaseAll()
super.onDetachedFromWindow()
}
private fun relayout() {
if (width <= 0 || height <= 0) return
fit = if (buttonsOnly && !editing) 1f else fitScale()
for (control in controls) control.radius = if (buttonsOnly && !editing && (control.id == "guide" || control.id == "qam")) dp(30f) else scaled(control.radiusDp)
if (buttonsOnly && !editing) {
val inset = dp(44f)
put("guide", safe.left + inset, height - safe.bottom - inset)
put("qam", width - safe.right - inset, height - safe.bottom - inset)
} else {
placeAuto(width.toFloat(), height.toFloat())
if (!ignoreSaved) for ((group, pos) in ControllerPrefs.layout(context, width, height)) {
if (group in groups) put(group, pos.first * width, pos.second * height)
}
}
groups.forEach { clamp(it) }
invalidate()
}
private fun pxPerMm(dpi: Float): Float {
val nominal = resources.displayMetrics.densityDpi.toFloat()
return (if (dpi > nominal * 0.6f && dpi < nominal * 1.6f) dpi else nominal) / 25.4f
}
private fun placeAuto(w: Float, h: Float) {
val metrics = resources.displayMetrics
val mmX = pxPerMm(metrics.xdpi)
val mmY = pxPerMm(metrics.ydpi)
val usableW = w - safe.left - safe.right
val usableH = h - safe.top - safe.bottom
val inboard = (0.17f * usableW / mmX).coerceIn(24f, 40f) * mmX
val lift = (0.285f * usableH / mmY).coerceIn(22f, 45f) * mmY
val gap = dp(12f)
val floor = BOTTOM_MARGIN_MM * mmY
val drop = max(drop("dpad", "ls", gap), drop("rs", "face", gap))
val restY = min(h - safe.bottom - lift, h - safe.bottom - floor - drop)
put("ls", safe.left + inboard, restY)
put("face", w - safe.right - inboard, restY)
besideBelow("dpad", "ls", 1f, gap, floor)
besideBelow("rs", "face", -1f, gap, floor)
val phone = usableH / mmY < PHONE_MAX_HEIGHT_MM
val shoulderY = if (phone) safe.top + floor + halfH("lb")
else min(centre("ls").second - extent("ls"), centre("face").second - extent("face")) - gap - halfH("lb")
shoulders("ls", "lb", "lt", 1f, gap, shoulderY)
shoulders("face", "rb", "rt", -1f, gap, shoulderY)
val bottom = h - safe.bottom - floor - extent("guide")
val spread = extent("guide") + gap / 2f
put("guide", w / 2f - spread, bottom)
put("qam", w / 2f + spread, bottom)
val inner = max(centre("dpad").first + extent("dpad"), w - centre("rs").first + extent("rs")) + gap + extent("select")
put("select", inner, restY)
put("start", w - inner, restY)
if (phone) {
val top = max(centre("lb").first + extent("lb"), w - centre("rb").first + extent("rb")) + gap * 2.5f + extent("select")
put("select", top, shoulderY)
put("start", w - top, shoulderY)
}
if (crowded("select", gap) || crowded("start", gap)) {
above("select", "dpad", gap)
val (sx, sy) = centre("select")
put("start", w - sx, sy)
if (crowded("start", gap)) above("start", "rs", gap)
}
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val row = centre("select").second
put("guide", w / 2f - spread, row)
put("qam", w / 2f + spread, row)
if (crowded("guide", gap, gap, "qam") || crowded("qam", gap, gap, "guide")) {
val top = safe.top + dp(8f) + extent("guide")
put("guide", w / 2f - spread, top)
put("qam", w / 2f + spread, top)
}
}
}
private fun drop(group: String, anchor: String, gap: Float): Float =
(extent(anchor) + extent(group) + gap) * sin(Math.toRadians(DROP_DEGREES)).toFloat() + extent(group)
private fun besideBelow(group: String, anchor: String, inward: Float, gap: Float, margin: Float) {
val (ax, ay) = centre(anchor)
val reach = extent(anchor) + extent(group) + gap
val floor = height - safe.bottom - margin - extent(group)
val dy = min(reach * sin(Math.toRadians(DROP_DEGREES)).toFloat(), floor - ay)
val dx = sqrt(max(0f, reach * reach - dy * dy))
put(group, ax + inward * dx, ay + dy)
}
private fun shoulders(anchor: String, bumper: String, trigger: String, inward: Float, gap: Float, y: Float) {
val (ax, _) = centre(anchor)
val spread = extent(bumper) + gap / 2f
put(bumper, ax + inward * spread, y)
put(trigger, ax - inward * spread, y)
}
private fun above(group: String, anchor: String, gap: Float) {
val (ax, ay) = centre(anchor)
put(group, ax, ay - extent(anchor) - gap - extent(group))
}
private fun crowded(group: String, gap: Float, space: Float = gap / 2f, except: String? = null): Boolean {
val (x, y) = centre(group)
return groups.any { other ->
if (other == group || other == except) return@any false
val (ox, oy) = centre(other)
val dx = x - ox
val dy = y - oy
sqrt(dx * dx + dy * dy) < extent(group) + extent(other) + space
}
}
private fun members(group: String) = controls.filter { it.group == group }
private fun centre(group: String): Pair<Float, Float> {
val first = members(group).first()
return (first.cx - scaled(first.offsetXDp)) to (first.cy - scaled(first.offsetYDp))
}
private fun extent(group: String): Float = members(group).maxOf { control ->
sqrt(scaled(control.offsetXDp).let { it * it } + scaled(control.offsetYDp).let { it * it }) + control.halfW
}
private fun halfH(group: String): Float = members(group).maxOf { abs(scaled(it.offsetYDp)) + it.halfH }
private fun put(group: String, x: Float, y: Float) {
for (control in members(group)) {
control.cx = x + scaled(control.offsetXDp)
control.cy = y + scaled(control.offsetYDp)
}
}
private fun clamp(group: String) {
val list = members(group)
val margin = dp(4f)
val minX = list.minOf { it.cx - it.halfW } - safe.left - margin
val maxX = list.maxOf { it.cx + it.halfW } - (width - safe.right - margin)
val minY = list.minOf { it.cy - it.halfH } - safe.top - margin
val maxY = list.maxOf { it.cy + it.halfH } - (height - safe.bottom - margin)
val dx = if (minX < 0) -minX else if (maxX > 0) -maxX else 0f
val dy = if (minY < 0) -minY else if (maxY > 0) -maxY else 0f
if (dx == 0f && dy == 0f) return
for (control in list) { control.cx += dx; control.cy += dy }
}
fun saveLayout() {
if (width <= 0 || height <= 0) return
if (ignoreSaved) { ControllerPrefs.resetLayout(context, width, height); return }
ControllerPrefs.setLayout(context, width, height, groups.associateWith { group ->
val (x, y) = centre(group)
x / width to y / height
})
}
fun resetLayout() {
ControllerPrefs.resetLayout(context, width, height)
ignoreSaved = true
selected = null
relayout()
}
private fun label(control: Control): String = when (control.target) {
"ls" -> "L"
"rs" -> "R"
"select" -> "⧉"
"start" -> "☰"
"guide" -> if (buttonsOnly && !editing) "Steam" else "◉"
"qam" -> "⋯"
else -> control.target.uppercase()
}
override fun onDraw(canvas: Canvas) {
if (!quickHidden) for (control in controls) {
if (!isVisible(control)) continue
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
val held = control.pressedBy != -1
val radius = control.radius
if (control.stick >= 0) {
val bx = if (held) control.ax else control.cx
val by = if (held) control.ay else control.cy
fill.color = if (held) heldFill else stickFill
canvas.drawCircle(bx, by, radius, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(bx, by, radius, stroke)
val knob = radius * 0.46f
fill.color = if (held || control.clicked) heldFill else knobFill
canvas.drawCircle(bx + control.kx, by + control.ky, knob, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(bx + control.kx, by + control.ky, knob, stroke)
text.color = if (held) Color.WHITE else idleText
text.textSize = knob * 0.8f
canvas.drawText(label(control), bx + control.kx, by + control.ky + text.textSize * 0.35f, text)
} else if (control.wide) {
box.set(control.cx - control.halfW, control.cy - control.halfH, control.cx + control.halfW, control.cy + control.halfH)
val corner = control.halfH * 0.55f
fill.color = if (held) heldFill else idleFill
canvas.drawRoundRect(box, corner, corner, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawRoundRect(box, corner, corner, stroke)
text.color = if (held) Color.WHITE else idleText
text.textSize = control.halfH * 0.8f
canvas.drawText(label(control), control.cx, control.cy + text.textSize * 0.35f, text)
} else {
fill.color = if (held) heldFill else idleFill
canvas.drawCircle(control.cx, control.cy, radius, fill)
stroke.color = if (held) heldStroke else idleStroke
canvas.drawCircle(control.cx, control.cy, radius, stroke)
text.color = if (held) Color.WHITE else idleText
val direction = directions.indexOf(control.target)
if (direction >= 0) {
drawArrow(canvas, control.cx, control.cy, radius * 0.34f, direction)
continue
}
val name = label(control)
text.textSize = if (name.length > 2) dp(11f) else radius * if (name.length == 2) 0.62f else 0.85f
canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
}
}
if (editing) {
stroke.color = heldStroke
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
selected?.let { group ->
val (x, y) = centre(group)
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
}
} else if (!buttonsOnly) {
val (x, y) = quickCenter()
val radius = dp(18f)
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
canvas.drawCircle(x, y, radius, fill)
stroke.color = idleStroke
canvas.drawCircle(x, y, radius, stroke)
text.color = idleText
text.textSize = dp(22f)
canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
}
if (!editing && settings.keyboardButton && onKeyboard != null) {
val (x, y) = keyboardCenter()
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
canvas.drawCircle(x, y, dp(18f), fill)
stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke
canvas.drawCircle(x, y, dp(18f), stroke)
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
for (row in 0..1) for (col in 0..2) {
val kx = x + dp(-6f + col * 5f)
val ky = y + dp(-3f + row * 4f)
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
}
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
}
}
private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f))
private fun keyboardCenter() = if (buttonsOnly)
width / 2f to (height - safe.bottom - dp(44f))
else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f))
private fun keyboardContains(x: Float, y: Float): Boolean {
if (!settings.keyboardButton || onKeyboard == null) return false
val (cx, cy) = keyboardCenter()
val dx = x - cx
val dy = y - cy
return dx * dx + dy * dy <= dp(26f) * dp(26f)
}
private fun quickContains(x: Float, y: Float): Boolean {
val (cx, cy) = quickCenter()
val radius = dp(26f)
val dx = x - cx
val dy = y - cy
return dx * dx + dy * dy <= radius * radius
}
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
val (fx, fy) = when (direction) {
0 -> 0f to -1f
1 -> 1f to 0f
2 -> 0f to 1f
else -> -1f to 0f
}
arrow.reset()
arrow.moveTo(x + fx * size, y + fy * size)
arrow.lineTo(x - fx * size * 0.7f - fy * size, y - fy * size * 0.7f + fx * size)
arrow.lineTo(x - fx * size * 0.7f + fy * size, y - fy * size * 0.7f - fx * size)
arrow.close()
fill.color = text.color
canvas.drawPath(arrow, fill)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (editing) return onEditTouch(event)
if (event.getToolType(event.actionIndex) == MotionEvent.TOOL_TYPE_MOUSE) return false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val x = event.getX(index)
val y = event.getY(index)
if (event.actionMasked == MotionEvent.ACTION_DOWN) forwardedPointerBits = 0
if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN && forwardMiss(event, x, y)) return true
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) {
keyboardPressedBy = event.getPointerId(index)
invalidate()
return true
}
if (keyboardPressedBy != -1) return true
if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) {
quickPressedBy = event.getPointerId(index)
return true
}
if (quickPressedBy != -1) return true
if (quickHidden) return false
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
if (control.pressedBy != -1) return true
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
control.drag(event.getX(index), event.getY(index))
control.dirty = true
}
if (SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event)
apply()
return true
}
MotionEvent.ACTION_MOVE -> {
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
if (keyboardPressedBy != -1) return true
if (quickPressedBy != -1) return true
var changed = false
var stickMoved = false
for (index in 0 until event.pointerCount) {
val pointer = event.getPointerId(index)
if (forwardedPointerBits and (1 shl pointer) != 0) continue
val x = event.getX(index)
val y = event.getY(index)
val stick = controls.firstOrNull { it.stick >= 0 && it.pressedBy == pointer }
if (stick != null) { stickMoved = stick.drag(x, y) || stickMoved; continue }
val over = controlAt(x, y)?.takeIf { it.stick < 0 }
for (control in controls) {
if (control.stick < 0 && control.pressedBy == pointer && control !== over) {
control.pressedBy = -1
changed = true
}
}
if (over != null && over.pressedBy == -1) {
over.pressedBy = pointer
changed = true
}
}
if (changed) apply() else if (stickMoved) apply(stickOnly = true)
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
val pointer = event.getPointerId(event.actionIndex)
if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits)
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
forwardedPointerBits = 0
releaseAll()
return true
}
if (forwardedPointerBits and (1 shl pointer) != 0) {
forwardedPointerBits = forwardedPointerBits and (1 shl pointer).inv()
return true
}
if (keyboardPressedBy == pointer) {
val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y)
keyboardPressedBy = -1
invalidate()
if (tapped) { releaseAll(); onKeyboard?.invoke() }
return true
}
if (quickPressedBy == pointer) {
val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y)
quickPressedBy = -1
if (toggled) setQuickHidden(!quickHidden)
return true
}
if (quickHidden) return false
var changed = false
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
control.pressedBy = -1
control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
control.kx = 0f; control.ky = 0f
}
}
if (changed) apply()
return true
}
}
return false
}
private fun forwardMiss(event: MotionEvent, x: Float, y: Float): Boolean {
if (onTouchMiss == null || keyboardPressedBy != -1 || quickPressedBy != -1) return false
if (!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null) return false
forwardedPointerBits = forwardedPointerBits or (1 shl event.getPointerId(event.actionIndex))
forwardPointers(event, forwardedPointerBits)
return true
}
private fun forwardPointers(event: MotionEvent, pointerIdBits: Int) {
val split = event.pointerSubset(pointerIdBits) ?: return
try {
onTouchMiss?.invoke(split)
} finally {
split.recycle()
}
}
private fun onEditTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
clearSnapGuides()
val control = controlAt(event.x, event.y)
selected = control?.group
val group = selected ?: run { invalidate(); return false }
val (x, y) = centre(group)
grabX = x - event.x
grabY = y - event.y
invalidate()
}
MotionEvent.ACTION_MOVE -> {
val group = selected ?: return true
ignoreSaved = false
val rawX = event.x + grabX
val rawY = event.y + grabY
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
put(group, nextX ?: rawX, nextY ?: rawY)
clamp(group)
val (x, y) = centre(group)
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
snapX = visibleX
snapY = visibleY
invalidate()
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
}
return true
}
private fun closestSnap(position: Float, targets: List<Float>): Float? =
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
fun takesTouchAt(x: Float, y: Float): Boolean =
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull {
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
}
private fun adaptiveStickAt(x: Float, y: Float): Control? {
if (!settings.adaptiveSticks || editing || buttonsOnly || quickHidden) return null
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
if (controls.any { control ->
control.stick < 0 && isVisible(control) && control.contains(x, y, control.radius, 5f)
}) return null
return controls.filter { control ->
val dx = if (control.stick == 0) max(0f, x - control.cx) else x - control.cx
val dy = y - control.cy
val reach = control.radius * 1.4f * sqrt(1.5f)
control.stick >= 0 && isVisible(control) && control.pressedBy == -1 &&
dx * dx + dy * dy <= reach * reach
}.minByOrNull { control ->
val dx = x - control.cx
val dy = y - control.cy
dx * dx + dy * dy
}
}
private fun isVisible(control: Control): Boolean = when {
!editing && control.id == "guide" && !settings.steamButton -> false
!editing && control.id == "qam" && !settings.qamButton -> false
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
else -> control.target != ControllerPrefs.OFF
}
private fun apply(stickOnly: Boolean = false) {
val bridge = pad
if (bridge == null || editing) { invalidate(); return }
val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 }
if (buttonsOnly) {
bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld)
} else {
bridge.setSystemButtons(false, qamHeld)
val stickWrites = controls.filter { it.stick >= 0 && it.dirty }.fold(0) { mask, control ->
mask or if (control.stick == 0) PadBridge.LEFT_STICK else PadBridge.RIGHT_STICK
}
bridge.applyTouch(stickWrites) { state ->
val used = HashSet<String>()
val held = HashSet<String>()
for (control in controls) {
when {
control.stick >= 0 -> {
val click = if (control.stick == 0) "l3" else "r3"
if (settings.stickClick) used.add(click)
if (control.clicked) held.add(click)
if (!control.dirty) continue
if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius }
else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius }
control.dirty = false
}
control.id == "qam" || control.target == ControllerPrefs.OFF -> {}
else -> {
used.add(control.target)
if (control.pressedBy != -1) held.add(control.target)
}
}
}
for (target in used) write(state, target, target in held)
}
}
if (stickOnly) stickRedraw.request() else invalidate()
}
private fun write(state: PadState, target: String, down: Boolean) {
when (target) {
"a" -> state.press(PadState.A, down)
"b" -> state.press(PadState.B, down)
"x" -> state.press(PadState.X, down)
"y" -> state.press(PadState.Y, down)
"lb" -> state.press(PadState.LB, down)
"rb" -> state.press(PadState.RB, down)
"select" -> state.press(PadState.SELECT, down)
"start" -> state.press(PadState.START, down)
"l3" -> state.press(PadState.L3, down)
"r3" -> state.press(PadState.R3, down)
"guide" -> state.press(PadState.GUIDE, down)
"lt" -> state.leftTrigger = if (down) 1f else 0f
"rt" -> state.rightTrigger = if (down) 1f else 0f
"up" -> state.up = down
"right" -> state.right = down
"down" -> state.down = down
"left" -> state.left = down
}
}
fun releaseAll() {
quickPressedBy = -1
keyboardPressedBy = -1
invalidate()
if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
controls.forEach {
it.pressedBy = -1
it.clicked = false
it.lastUp = 0L
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
it.kx = 0f; it.ky = 0f
}
apply()
}
private companion object {
const val DOUBLE_TAP_MS = 300L
const val STICK_REDRAW_MS = 33L
const val MIN_STICK_MOVE_PX = 1f
const val MIN_FACE_MM = 9.5f
const val FULL_SIZE_HEIGHT_MM = 85f
const val BOTTOM_MARGIN_MM = 5f
const val DROP_DEGREES = 35.0
const val PHONE_MAX_HEIGHT_MM = 90f
const val SHOULDER_WIDTH = 1.45f
const val SHOULDER_HEIGHT = 0.8f
const val SNAP_DP = 9f
const val SNAP_TOLERANCE_PX = 1f
val shoulderIds = setOf("lb", "rb", "lt", "rt")
val directions = listOf("up", "right", "down", "left")
const val QUICK_CENTER_DP = 34f
const val QUICK_HIT_DP = 26f
const val QUICK_RADIUS_DP = 18f
const val PLACEMENT_CACHE = 4
}
}
@@ -0,0 +1,95 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Rect
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
object OnScreenLayout {
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"
fun elements(context: Context, layout: SavedInputLayout?, width: Int, height: Int, safe: Rect,
buttonsOnly: Boolean = false, dock: Dock? = null): List<InputSurfaceElement> {
val stored = layout?.elements ?: return emptyList()
if (width <= 0 || height <= 0) return stored
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
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) || 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.left, dock.right)
else -> generator.regenerate(gamepad)
}
val byId = (placed + others).associateBy { it.id }
return stored.mapNotNull { byId[it.id] } + placed.filter { p -> stored.none { it.id == p.id } }
}
fun fit(elements: List<InputSurfaceElement>, referenceWidth: Float, referenceHeight: Float, width: Int, height: Int, safe: Rect): List<InputSurfaceElement> {
val w = width.toFloat()
val h = height.toFloat()
val scale = min(w / referenceWidth, h / referenceHeight)
return elements.map { e ->
val ew = e.width * referenceWidth * scale
val eh = e.height * referenceHeight * scale
val left = anchor(e.x, e.width, referenceWidth, w, scale).coerceIn(safe.left.toFloat(), max(safe.left.toFloat(), w - safe.right - ew))
val top = anchor(e.y, e.height, referenceHeight, h, scale).coerceIn(safe.top.toFloat(), max(safe.top.toFloat(), h - safe.bottom - eh))
e.copy(x = left / w, y = top / h, width = ew / w, height = eh / h).bounded()
}
}
private fun anchor(start: Float, size: Float, reference: Float, actual: Float, scale: Float): Float {
val centre = start + size / 2f
val scaled = size * reference * scale
return when {
centre < 1f / 3f -> start * reference * scale
centre > 2f / 3f -> actual - (1f - start - size) * reference * scale - scaled
else -> centre * actual - scaled / 2f
}
}
private fun isGamepad(e: InputSurfaceElement) = e.id.startsWith("legacy:") || e.id == KEYBOARD_ID
fun generatedSize(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Int, Int>? =
reference(context, layout, width, height).takeIf { it.second }?.first?.let { it.first.toInt() to it.second.toInt() }
private fun reference(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair<Pair<Float, Float>, Boolean> {
val (display, metrics) = InputLibraries.primaryDisplay(context)
val long = max(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
val short = min(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
val gamepad = layout.elements.filter(::isGamepad)
if (gamepad.any { it.id.startsWith("legacy:") }) {
val named = layout.id.removePrefix("migrated.layout.").takeIf { it != layout.id }?.split('x')?.mapNotNull { it.toIntOrNull() }
?.takeIf { it.size == 2 && it.all { size -> size > 0 } }?.let { it[0] to it[1] }
for ((w, h) in listOfNotNull(named, width to height, long to short, short to long).distinct()) {
if (matches(gamepad, LegacyGamepadLayout(display, w, h, scale = layout.scale).elements())) return (w.toFloat() to h.toFloat()) to true
}
}
val aspect = layout.aspect.takeIf { it > 0f } ?: (long.toFloat() / short)
return (aspect to 1f) to false
}
private fun matches(stored: List<InputSurfaceElement>, generated: List<InputSurfaceElement>): Boolean {
val byId = generated.associateBy { it.id }
return stored.all { e ->
val g = byId[e.id] ?: return@all false
abs(e.x - g.x) <= TOLERANCE && abs(e.y - g.y) <= TOLERANCE && abs(e.width - g.width) <= TOLERANCE && abs(e.height - g.height) <= TOLERANCE
}
}
}
@@ -59,11 +59,25 @@ public final class PadBridge {
private final int[] retainedSticks = new int[SLOTS];
private final PadState effectiveState = new PadState();
private final PadState homeGuideReport = new PadState();
private final PadState combinedState = new PadState();
private InputRouter inputRouter;
private final InputRouter.PadSource[] physicalRoutes = new InputRouter.PadSource[SLOTS];
private final java.util.IdentityHashMap<Object, TouchSource> touchSources = new java.util.IdentityHashMap<>();
private final Object defaultTouchSource = new Object();
private static final class TouchSource {
final PadState raw = new PadState();
final PadState passthrough = new PadState();
final InputRole role;
boolean routed;
InputRouter.PadSource route;
TouchSource(InputRole role) { this.role = role; }
}
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private volatile boolean builtinPad = true;
private TouchRoute touchRoute;
private boolean touchMouseActive;
private final PadState routedTouch = new PadState();
private boolean systemGuidePressed;
private boolean systemQamPressed;
@@ -72,6 +86,7 @@ public final class PadBridge {
private int homeGuideSlot = -1;
private final PadState lastBackQamReport = new PadState();
private final PadState lastWrittenReport = new PadState();
private final PadState liveReport = new PadState();
private boolean backQamAxesBlocked;
private final java.util.Set<Runnable> backQamWrites = new java.util.HashSet<>();
private long backQamDelayMs, backQamWriteAt;
@@ -79,6 +94,11 @@ public final class PadBridge {
private int chordButton = -1;
private boolean chordButtonPressed;
private boolean qamTapPressed;
private boolean guideTapPressed;
private int guideTapGeneration;
private boolean vrStickTapPressed;
private int vrStickTapGeneration;
private boolean routedQamPressed;
private int qamChordGeneration;
private volatile Runnable onPlayerInput;
private Runnable onChordInput;
@@ -119,8 +139,36 @@ public final class PadBridge {
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
}
public void setSelectSteam(boolean on) {
selectSteam = on;
public synchronized void setInputRouter(InputRouter router) {
if (inputRouter == router) return;
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
inputRouter = router;
for (int slot = 0; slot < SLOTS; slot++) physicalRoutes[slot] = router == null ? null : router.pad(InputRole.PHYSICAL, stateFor(slot));
for (TouchSource source : touchSources.values()) source.route = router == null || source.routed ? null : router.pad(source.role, source.raw);
clearBackQamWrites();
if (open) publish();
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
}
public synchronized boolean hasInputRouter() { return inputRouter != null; }
public synchronized InputRouter getInputRouter() { return inputRouter; }
public synchronized void setInputProfile(InputProfile profile) {
if (inputRouter == null) return;
inputRouter.setProfile(profile);
clearBackQamWrites();
if (open) publish();
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
}
public synchronized void setInputEnabled(boolean enabled) {
if (inputRouter == null || !inputRouter.setEnabled(enabled)) return;
if (!enabled) clearBackQamWrites();
if (open) publish();
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
DeckControls.INSTANCE.refresh();
}
public void setExcluded(java.util.function.IntPredicate devices) {
@@ -147,10 +195,18 @@ public final class PadBridge {
homeGuidePressed = false;
clearBackQamWrites();
routedTouch.clear();
for (TouchSource source : touchSources.values()) {
source.raw.clear();
if (source.route != null) source.route.release();
}
systemGuidePressed = false;
systemQamPressed = false;
backQamPressed = false;
qamTapPressed = false;
guideTapPressed = false;
guideTapGeneration++;
vrStickTapPressed = false;
vrStickTapGeneration++;
qamChordActive = false;
chordButtonPressed = false;
qamChordGeneration++;
@@ -168,6 +224,16 @@ public final class PadBridge {
}
public synchronized void stop() {
guideTapPressed = false;
guideTapGeneration++;
vrStickTapPressed = false;
vrStickTapGeneration++;
if (inputRouter != null) inputRouter.releaseAll();
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
touchSources.clear();
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
Arrays.fill(physicalRoutes, null);
inputRouter = null;
homeGuidePressed = false;
homeGuideSlot = -1;
clearBackQamWrites();
@@ -314,6 +380,7 @@ public final class PadBridge {
if (slot < 0) return false;
int side = JoyCon.side(event.getDevice());
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
long timing = InputTiming.begin();
PadState pad = stateFor(slot);
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
@@ -324,9 +391,6 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
case KeyEvent.KEYCODE_BUTTON_SELECT:
if (selectSteam) pad.press(PadState.GUIDE, pressed);
else pad.press(6, pressed);
break;
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
case KeyEvent.KEYCODE_BUTTON_START:
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
@@ -341,8 +405,14 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_C:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_L2:
pad.leftTrigger = keyTrigger(pad.leftTrigger, pressed, event.getRepeatCount(),
analogTrigger(event.getDevice(), MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE));
break;
case KeyEvent.KEYCODE_BUTTON_R2:
pad.rightTrigger = keyTrigger(pad.rightTrigger, pressed, event.getRepeatCount(),
analogTrigger(event.getDevice(), MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS));
break;
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
@@ -355,6 +425,7 @@ public final class PadBridge {
statButtons++;
scheduleStats();
publish(slot);
InputTiming.end(timing);
return true;
}
@@ -422,6 +493,7 @@ public final class PadBridge {
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
int slot = slotFor(event.getDevice());
if (slot < 0) return false;
long timing = InputTiming.begin();
PadState pad = stateFor(slot);
noteDevice(event.getDevice(), slot);
statAxes++;
@@ -438,6 +510,7 @@ public final class PadBridge {
if (chordStickInput) noteChordInput(slot);
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
publish(slot);
InputTiming.end(timing);
return true;
}
float lx = event.getAxisValue(MotionEvent.AXIS_X);
@@ -476,9 +549,20 @@ public final class PadBridge {
notePlayerInput();
}
publish(slot);
InputTiming.end(timing);
return true;
}
static float keyTrigger(float current, boolean pressed, int repeatCount, boolean analog) {
if (analog || repeatCount > 0) return current;
return pressed ? 1f : 0f;
}
static boolean analogTrigger(InputDevice device, int... axes) {
return wiredXboxLayout(device, InputDevice.SOURCE_JOYSTICK)
|| hasAxis(device, InputDevice.SOURCE_JOYSTICK, axes);
}
static boolean hasAxis(InputDevice device, int source, int... axes) {
if (device == null) return false;
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
@@ -493,41 +577,70 @@ public final class PadBridge {
public synchronized void setTouchRoute(TouchRoute route) {
touchRoute = route;
touchMouseActive = false;
routedTouch.clear();
}
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
applyTouch(0, mutation);
applyTouch(defaultTouchSource, InputRole.PRIMARY, 0, mutation);
}
public synchronized void applyTouch(int stickWrites, java.util.function.Consumer<PadState> mutation) {
applyTouch(defaultTouchSource, InputRole.PRIMARY, stickWrites, mutation);
}
public synchronized void applyTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
applyTouch(owner, role, 0, mutation);
}
public synchronized void applyTouch(Object owner, InputRole role, int stickWrites, java.util.function.Consumer<PadState> mutation) {
if (!builtinPad) return;
if (touchRoute != null && touchRoute.active()) {
mutation.accept(routedTouch);
touchRoute.accept(routedTouch);
boolean changed = false;
for (int button : ROUTED_PASS_THROUGH) {
boolean down = routedTouch.isDown(button);
if (state.isDown(button) == down) continue;
state.press(button, down);
changed = true;
long timing = InputTiming.begin();
TouchSource source = touchSources.get(owner);
if (source == null) {
source = new TouchSource(role);
if (inputRouter != null) source.route = inputRouter.pad(role);
touchSources.put(owner, source);
}
float lx = source.raw.leftX, ly = source.raw.leftY, rx = source.raw.rightX, ry = source.raw.rightY;
mutation.accept(source.raw);
if (inputRouter == null && (touchRoute == null || !touchRoute.active())) {
if ((stickWrites & LEFT_STICK) != 0 || source.raw.leftX != lx || source.raw.leftY != ly) {
retainedSticks[0] &= ~LEFT_STICK;
state.leftX = state.leftY = 0f;
}
if (changed) {
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
publish();
if ((stickWrites & RIGHT_STICK) != 0 || source.raw.rightX != rx || source.raw.rightY != ry) {
retainedSticks[0] &= ~RIGHT_STICK;
state.rightX = state.rightY = 0f;
}
return;
}
routedTouch.clear();
retainedSticks[0] &= ~stickWrites;
mutateState(mutation);
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
notePlayerInput();
statOnScreen++;
scheduleStats();
publish();
InputTiming.end(timing);
}
public synchronized void releaseTouch(Object owner) {
TouchSource source = touchSources.remove(owner);
if (source == null) return;
if (source.route != null) source.route.close();
publish();
}
public synchronized void applyRoutedTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
if (!builtinPad) return;
TouchSource source = touchSources.get(owner);
if (source == null) {
source = new TouchSource(role);
source.routed = true;
touchSources.put(owner, source);
}
mutation.accept(source.raw);
notePlayerInput();
publish();
}
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
@@ -552,16 +665,30 @@ public final class PadBridge {
}
public synchronized void releaseAll() {
guideTapGeneration++;
vrStickTapPressed = false;
vrStickTapGeneration++;
routedQamPressed = false;
systemGuidePressed = false;
systemQamPressed = false;
guideTapPressed = false;
qamTapPressed = false;
qamChordActive = false;
chordButtonPressed = false;
qamChordGeneration++;
if (inputRouter != null) inputRouter.releaseAll();
if (inputRouter == null) for (TouchSource source : touchSources.values()) source.raw.clear();
routedTouch.clear();
DeckControls.INSTANCE.suspend();
if (inputRouter == null) clearPlayerState(0);
homeGuidePressed = false;
homeGuideSlot = -1;
clearBackQamWrites();
routedTouch.clear();
backQamPressed = false;
releaseBackPaddles();
clearPlayerState(0);
if (inputRouter == null) releaseBackPaddles();
publish();
for (int slot = 1; slot < SLOTS; slot++) {
clearPlayerState(slot);
if (inputRouter == null) clearPlayerState(slot);
if (playerWriters[slot] == null) continue;
publish(slot);
}
@@ -602,7 +729,11 @@ public final class PadBridge {
if (!homeGuidePressed) return;
clearBackQamWrites();
homeGuidePressed = false;
clearPlayerState(0);
if (inputRouter == null) clearPlayerState(0);
else {
inputRouter.releaseAll();
DeckControls.INSTANCE.suspend();
}
publish();
}
@@ -641,7 +772,11 @@ public final class PadBridge {
if (!backQamPressed && backQamWrites.isEmpty()) return;
clearBackQamWrites();
backQamPressed = false;
clearPlayerState(0);
if (inputRouter == null) clearPlayerState(0);
else {
inputRouter.releaseAll();
DeckControls.INSTANCE.suspend();
}
publish();
}
@@ -677,6 +812,7 @@ public final class PadBridge {
}
private void writePad(PadState report) {
liveReport.copyFrom(report);
if (backQamAxesBlocked && !hasQamAxes()) backQamAxesBlocked = false;
long at = android.os.SystemClock.uptimeMillis();
if (backQamPressed || homeGuidePressed || !backQamWrites.isEmpty()) at += backQamDelayMs;
@@ -684,11 +820,11 @@ public final class PadBridge {
}
private boolean hasQamAxes() {
boolean left = (retainedSticks[0] & 1) != 0
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : state.leftX != 0f || state.leftY != 0f;
boolean right = (retainedSticks[0] & 2) != 0
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : state.rightX != 0f || state.rightY != 0f;
return left || right || state.leftTrigger != 0f || state.rightTrigger != 0f;
boolean left = (retainedSticks[0] & 1) != 0 && liveReport.leftX == state.leftX && liveReport.leftY == state.leftY
? hasChordStickInput(rawSticks[0][0], rawSticks[0][1]) : liveReport.leftX != 0f || liveReport.leftY != 0f;
boolean right = (retainedSticks[0] & 2) != 0 && liveReport.rightX == state.rightX && liveReport.rightY == state.rightY
? hasChordStickInput(rawSticks[0][2], rawSticks[0][3]) : liveReport.rightX != 0f || liveReport.rightY != 0f;
return left || right || liveReport.leftTrigger != 0f || liveReport.rightTrigger != 0f;
}
private static void clearAxes(PadState pad) {
@@ -709,9 +845,9 @@ public final class PadBridge {
PadState live = new PadState();
live.copyFrom(report);
if (queued) {
live.leftX = state.leftX; live.leftY = state.leftY;
live.rightX = state.rightX; live.rightY = state.rightY;
live.leftTrigger = state.leftTrigger; live.rightTrigger = state.rightTrigger;
live.leftX = liveReport.leftX; live.leftY = liveReport.leftY;
live.rightX = liveReport.rightX; live.rightY = liveReport.rightY;
live.leftTrigger = liveReport.leftTrigger; live.rightTrigger = liveReport.rightTrigger;
}
boolean held = (backQamPressed && report.isDown(PadState.QAM)) || (homeGuidePressed && report.isDown(PadState.GUIDE));
if ((queued && !held) || backQamAxesBlocked) clearAxes(live);
@@ -732,6 +868,34 @@ public final class PadBridge {
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
}
public synchronized void triggerSteamMenu() {
if (!builtinPad || guideTapPressed) return;
guideTapPressed = true;
int generation = ++guideTapGeneration;
publish();
chordHandler.postDelayed(() -> {
synchronized (PadBridge.this) {
if (generation != guideTapGeneration) return;
guideTapPressed = false;
publish();
}
}, 90);
}
public synchronized void triggerVrStickSwitch() {
if (!builtinPad || vrStickTapPressed) return;
vrStickTapPressed = true;
int generation = ++vrStickTapGeneration;
publish();
chordHandler.postDelayed(() -> {
synchronized (PadBridge.this) {
if (generation != vrStickTapGeneration) return;
vrStickTapPressed = false;
publish();
}
}, 120);
}
private synchronized void releaseQamTap(int generation) {
if (generation != qamChordGeneration || !qamTapPressed) return;
qamTapPressed = false;
@@ -856,6 +1020,7 @@ public final class PadBridge {
}
private void free(int slot) {
if (physicalRoutes[slot] != null) physicalRoutes[slot].release();
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
owners[slot] = NO_CONTROLLER;
partners[slot] = NO_CONTROLLER;
@@ -909,6 +1074,7 @@ public final class PadBridge {
publish();
return;
}
PadState output = physicalRoutes[slot] == null ? playerStates[slot] : physicalRoutes[slot].route(playerStates[slot]);
FakeInputWriter player = playerWriters[slot];
if (player == null) {
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
@@ -918,43 +1084,75 @@ public final class PadBridge {
}
playerWriters[slot] = player;
}
PadState report = playerStates[slot];
if (slot == homeGuideSlot) {
homeGuideReport.copyFrom(report);
homeGuideReport.copyFrom(output);
homeGuideReport.press(PadState.GUIDE, true);
report = homeGuideReport;
output = homeGuideReport;
}
player.writePad(report);
player.writePad(output);
}
private void publish() {
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
PadState output = physicalRoutes[0] == null ? state : physicalRoutes[0].route(state);
boolean mouseRoute = (!touchSources.isEmpty() || touchMouseActive) && touchRoute != null && touchRoute.active();
touchMouseActive = mouseRoute && !touchSources.isEmpty();
if (mouseRoute) routedTouch.clear();
if (!touchSources.isEmpty()) {
combinedState.copyFrom(output);
for (TouchSource source : touchSources.values()) {
PadState touch = source.route == null ? source.raw : source.route.route(source.raw);
if (mouseRoute) {
routedTouch.merge(touch);
source.passthrough.clear();
for (int button : ROUTED_PASS_THROUGH) source.passthrough.press(button, touch.isDown(button));
combinedState.merge(source.passthrough);
} else combinedState.merge(touch);
}
output = combinedState;
}
if (mouseRoute) touchRoute.accept(routedTouch);
boolean guide = systemGuidePressed || qamChordActive || guideTapPressed || homeGuidePressed;
boolean routedQam = output.isDown(PadState.QAM);
boolean routedQamStarted = routedQam && !routedQamPressed;
routedQamPressed = routedQam;
if (routedQamStarted && !nativeQam() && !qamChordActive) {
startChord(PadState.A);
return;
}
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
if (!open && !start()) {
statDropped++;
return;
}
boolean guide = systemGuidePressed || qamChordActive || homeGuidePressed;
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
if (!guide && !qam) {
writePad(state);
if (!guide && !qam && !vrStickTapPressed && (!routedQam || nativeQam())) {
writePad(output);
return;
}
effectiveState.copyFrom(state);
effectiveState.copyFrom(output);
if (!nativeQam()) effectiveState.press(PadState.QAM, false);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (chordButtonPressed) effectiveState.press(chordButton, true);
if (vrStickTapPressed) {
effectiveState.press(PadState.L3, true);
effectiveState.press(PadState.R3, true);
}
writePad(effectiveState);
}
public static boolean wiredXboxLayout(MotionEvent event) {
InputDevice device = event.getDevice();
return wiredXboxLayout(event.getDevice(), event.getSource());
}
static boolean wiredXboxLayout(InputDevice device, int source) {
return device != null
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
&& device.getMotionRange(MotionEvent.AXIS_RX, source) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, source) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, source) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, source) != null;
}
public static float leftTrigger(MotionEvent event, boolean wired) {
@@ -36,6 +36,19 @@ class PadState {
buttons = 0
}
fun merge(other: PadState) {
buttons = buttons or other.buttons
up = up || other.up; right = right || other.right; down = down || other.down; left = left || other.left
leftTrigger = maxOf(leftTrigger, other.leftTrigger)
rightTrigger = maxOf(rightTrigger, other.rightTrigger)
if (other.leftX * other.leftX + other.leftY * other.leftY > leftX * leftX + leftY * leftY) {
leftX = other.leftX; leftY = other.leftY
}
if (other.rightX * other.rightX + other.rightY * other.rightY > rightX * rightX + rightY * rightY) {
rightX = other.rightX; rightY = other.rightY
}
}
companion object {
const val A = 0
const val B = 1
@@ -0,0 +1,89 @@
package com.droiddeck.launcher.input
import android.view.InputDevice
import android.view.KeyEvent
data class PhysicalInputKey(val device: String, val name: String, val keyCode: Int, val scanCode: Int) {
val label: String get() = "$name · ${KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")} ($scanCode)"
companion object {
fun identity(device: InputDevice) = "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
fun capture(event: KeyEvent): PhysicalInputKey? {
val device = event.device ?: return null
if (device.isVirtual || PadBridge.isSystemVirtualInputName(device.name)) return null
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE,
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
}
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
return PhysicalInputKey(identity(device), device.name, event.keyCode, event.scanCode)
}
}
}
data class PhysicalInputBinding(val key: PhysicalInputKey, val action: InputAction)
class PhysicalInputBindings(private val router: InputRouter) {
private class Entry(val binding: PhysicalInputBinding, val channel: InputRouter.Channel) {
var heldDevice = -1
var suppressed = false
}
private var entries = emptyList<Entry>()
private val released = ArrayList<Entry>()
private val devices = HashMap<Int, String>()
fun configure(bindings: List<PhysicalInputBinding>) {
if (entries.map { it.binding } == bindings) return
for (entry in entries) {
entry.channel.close()
if (entry.heldDevice != -1) released.add(entry)
}
entries = bindings.map { Entry(it, router.channel(InputRole.PHYSICAL, InputControl.A, it.action)) }
}
fun onKey(event: KeyEvent, enabled: Boolean): Boolean {
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
for (i in released.indices.reversed()) {
val old = released[i]
if (old.heldDevice == event.deviceId && old.binding.key.keyCode == event.keyCode && old.binding.key.scanCode == event.scanCode) {
if (event.action == KeyEvent.ACTION_UP) released.removeAt(i)
return true
}
}
for (entry in entries) {
val key = entry.binding.key
if (key.keyCode != event.keyCode || key.scanCode != event.scanCode) continue
if (entry.heldDevice != -1 && entry.heldDevice == event.deviceId) {
if (event.action == KeyEvent.ACTION_UP) {
entry.channel.update(0f); entry.heldDevice = -1; entry.suppressed = false
}
return true
}
if (!enabled || event.action != KeyEvent.ACTION_DOWN || entry.binding.action is InputAction.Native) continue
val identity = devices[event.deviceId] ?: event.device?.let { PhysicalInputKey.identity(it) }?.also { devices[event.deviceId] = it } ?: continue
if (identity != key.device) continue
entry.heldDevice = event.deviceId
entry.suppressed = event.repeatCount != 0
if (!entry.suppressed) entry.channel.update(1f)
return true
}
return false
}
fun release() {
for (entry in entries) {
entry.channel.release()
if (entry.heldDevice != -1) entry.suppressed = true
}
}
fun disconnected(id: Int) {
devices.remove(id)
released.removeAll { it.heldDevice == id }
for (entry in entries) if (entry.heldDevice == id) { entry.channel.release(); entry.heldDevice = -1 }
}
fun close() { entries.forEach { it.channel.close() }; entries = emptyList(); released.clear(); devices.clear() }
}
@@ -105,6 +105,10 @@ object PhysicalShortcuts {
write(context, read(context).filterNot { it == binding })
}
fun restoreDefault(context: Context, action: Action) {
write(context, read(context).filterNot { it.action == action })
}
private fun write(context: Context, bindings: List<Binding>) {
val array = JSONArray()
bindings.forEach {
@@ -1,52 +0,0 @@
package com.droiddeck.launcher.input
import android.content.Context
fun screenKeyLabel(context: Context, codes: List<Int>): String =
codes.filter { it != 0 }.joinToString("+") { short(context, it) }
private fun short(context: Context, code: Int): String = when (code) {
28 -> "⏎"
14 -> "⌫"
15 -> "Tab"
57 -> "Space"
58 -> "Caps"
42, 54 -> "Shift"
29, 97 -> "Ctrl"
56 -> "Alt"
100 -> "AltGr"
125 -> "Super"
else -> pcKeyLabel(context, code)
}
const val SCREEN_KEY_PILL = 0
const val SCREEN_KEY_ROUND = 1
const val SCREEN_KEY_CIRCLE = 2
const val SCREEN_KEY_RECT = 3
const val SCREEN_KEY_TRIGGER = 4
const val SCREEN_KEY_DPAD = 5
const val SCREEN_KEY_STICK = 6
val SCREEN_KEY_DPAD_DIRS = listOf("↑", "→", "↓", "←")
val SCREEN_KEY_STICK_DIRS = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
fun screenKeyDirections(shape: Int): List<String> = when (shape) {
SCREEN_KEY_DPAD -> SCREEN_KEY_DPAD_DIRS
SCREEN_KEY_STICK -> SCREEN_KEY_STICK_DIRS
else -> emptyList()
}
data class ScreenKey(
val codes: List<Int>,
val x: Float,
val y: Float,
val size: Int,
val glyph: String = "",
val text: String = "",
val image: String = "",
val opacity: Float = 1f,
val shape: Int = SCREEN_KEY_PILL,
val toggle: Boolean = false,
val labels: Boolean = true,
) {
}
File diff suppressed because it is too large. Load diff
@@ -1,140 +0,0 @@
package com.droiddeck.launcher.input
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.roundToInt
private val ICP_NAMES: Map<Int, String> = mapOf(
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
21 to "KEY_Y", 44 to "KEY_Z",
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
)
private val ICP_CODES: Map<String, Int> = ICP_NAMES.entries.associate { (code, name) -> name to code }
private fun icpShape(shape: Int): String = when (shape) {
SCREEN_KEY_CIRCLE -> "CIRCLE"
SCREEN_KEY_RECT -> "RECT"
SCREEN_KEY_ROUND -> "ROUND_RECT"
SCREEN_KEY_DPAD -> "ROUND_RECT"
SCREEN_KEY_STICK -> "ROUND_RECT"
SCREEN_KEY_TRIGGER -> "ROUND_RECT"
else -> "ROUND_RECT"
}
private fun screenKeyShape(shape: String): Int = when (shape) {
"CIRCLE" -> SCREEN_KEY_CIRCLE
"RECT" -> SCREEN_KEY_RECT
"SQUARE" -> SCREEN_KEY_ROUND
else -> SCREEN_KEY_PILL
}
fun exportScreenLayout(keys: List<ScreenKey>, widthPx: Int, density: Float): String {
val snap = widthPx / 100f
val elements = JSONArray()
keys.forEach { key ->
val binds = JSONArray()
val directional = key.shape == SCREEN_KEY_DPAD || key.shape == SCREEN_KEY_STICK
key.codes.forEach { code ->
val name = ICP_NAMES[code]
if (name != null) binds.put(name) else if (directional) binds.put("")
}
if (binds.length() == 0) return@forEach
val shape = icpShape(key.shape)
val sizePx = key.size * density
val scale = when {
key.shape == SCREEN_KEY_DPAD -> sizePx * 2.3f / (14f * snap)
key.shape == SCREEN_KEY_STICK -> sizePx * 2.3f / (14f * snap)
shape == "CIRCLE" -> sizePx / (6f * snap)
else -> sizePx / (4f * snap)
}
elements.put(
JSONObject()
.put(
"type",
when (key.shape) {
SCREEN_KEY_DPAD -> "D_PAD"
SCREEN_KEY_STICK -> "STICK"
else -> "BUTTON"
},
)
.put("shape", shape)
.put("customColor", 0)
.put("bindings", binds)
.put("scale", scale)
.put("x", key.x)
.put("y", key.y)
.put("toggleSwitch", key.toggle)
.put("swipeable", false)
.put("text", key.text)
.put("iconId", 0)
.apply { if (key.opacity < 1f) put("opacity", key.opacity) },
)
}
return JSONObject()
.put("id", 0)
.put("name", "DroidDeck")
.put("cursorSpeed", 1.5f)
.put("elements", elements)
.toString()
}
fun importScreenLayout(raw: String, widthPx: Int, density: Float): List<ScreenKey>? {
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val elements = root.optJSONArray("elements") ?: return null
val snap = widthPx / 100f
val keys = mutableListOf<ScreenKey>()
for (i in 0 until elements.length()) {
val element = elements.optJSONObject(i) ?: continue
val type = element.optString("type", "BUTTON")
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
val binds = element.optJSONArray("bindings") ?: continue
val raw = (0 until binds.length()).map { ICP_CODES[binds.optString(it)] ?: 0 }
val codes = if (type == "D_PAD" || type == "STICK") {
raw.dropLastWhile { it == 0 }.ifEmpty { listOf(0) }
} else {
raw.filter { it != 0 }
}
if (codes.isEmpty()) continue
val dpad = type == "D_PAD"
val stick = type == "STICK"
val shape = when {
dpad -> SCREEN_KEY_DPAD
stick -> SCREEN_KEY_STICK
else -> screenKeyShape(element.optString("shape"))
}
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it > 0f } ?: 1f
val sizePx = when {
dpad || stick -> 14f * snap * scale / 2.3f
shape == SCREEN_KEY_CIRCLE -> 6f * snap * scale
else -> 4f * snap * scale
}
keys += ScreenKey(
codes = codes,
x = element.optDouble("x", 0.5).toFloat().coerceIn(0f, 1f),
y = element.optDouble("y", 0.5).toFloat().coerceIn(0f, 1f),
size = (sizePx / density).roundToInt().coerceIn(ScreenKeys.MIN_SIZE, ScreenKeys.MAX_SIZE),
text = element.optString("text").take(24),
opacity = element.optDouble("opacity", 1.0).toFloat().coerceIn(0.2f, 1f),
shape = shape,
toggle = element.optBoolean("toggleSwitch"),
)
}
return keys
}
@@ -1,26 +1,34 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindings: String? = null) {
NONE("none", R.string.second_mode_none),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad, InputStockLayouts.TRACKPAD),
TERMINAL("terminal", R.string.second_mode_terminal),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls, InputStockLayouts.DECK),
CUSTOM_CONTROLS("custom-controls", R.string.input_profile_controls_mode),
DASHBOARD("dashboard", R.string.second_mode_dashboard),
DESKTOP("desktop", R.string.second_mode_desktop);
val controls: Boolean get() = this == CUSTOM_CONTROLS || bindings != null
fun panel(context: Context): String? = if (this == DECK_CONTROLS) SessionPrefs.deckPanel(context).bindings else bindings
companion object {
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, desktop: Boolean = false): SecondScreenMode {
fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, bottomDesktop: Boolean = false, desktopSession: Boolean = false): SecondScreenMode {
entries.firstOrNull {
it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
(it != DESKTOP || desktop)
(it != DESKTOP || bottomDesktop)
}?.let { return it }
val picked = entries.any { it != NONE && it.id == remembered }
return when {
desktopSession -> KEYBOARD_TRACKPAD
deckPad -> DECK_CONTROLS
dashboard && !picked -> DASHBOARD
else -> KEYBOARD_TRACKPAD
@@ -44,4 +52,7 @@ object SecondScreenDisplays {
)
}
.toList()
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
(listOf(selected) + available).distinct().filterNot(taken)
}
@@ -19,11 +19,20 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
private var published = false
private var shared: String? = null
private var echo: String? = null
private var capture: ((String?) -> Unit)? = null
private var captureBefore: String? = null
private var captureLate: (() -> Unit)? = null
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
private val guestListener = object : WaylandCompositor.ClipboardListener {
override fun onGuestClipboardText(text: String) {
handler.post {
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val taken = capture?.takeIf { text != captureBefore }
if (taken != null && WaylandCompositor.isClipboardListener(this)) {
capture = null
handler.removeCallbacks(captureTimeout)
WaylandCompositor.setClipboardText(shared)
taken(text)
} else if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
shared = text
published = true
@@ -36,6 +45,21 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
}
}
private val captureTimeout = Runnable {
val taken = capture ?: return@Runnable
capture = null
captureLate?.invoke()
taken(null)
}
fun captureGuest(timeoutMs: Long, onTimeout: () -> Unit = {}, onText: (String?) -> Unit) {
capture?.let { handler.removeCallbacks(captureTimeout); it(null) }
capture = onText
captureLate = onTimeout
captureBefore = shared
handler.postDelayed(captureTimeout, timeoutMs)
}
fun start() {
if (active) return
active = true
@@ -47,6 +71,8 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
fun stop() {
if (!active) return
active = false
handler.removeCallbacks(captureTimeout)
capture?.let { capture = null; it(null) }
clipboard.removePrimaryClipChangedListener(androidListener)
WaylandCompositor.clearClipboardListener(guestListener)
handler.removeCallbacksAndMessages(null)
@@ -20,7 +20,11 @@ import java.util.ArrayDeque
import java.util.UUID
@SuppressLint("MissingPermission")
class SteamControllerBle(private val context: Context) {
class SteamControllerBle(private val context: Context, router: InputRouter? = null) {
private val inputLock = router ?: Any()
private var routedInput = router?.let(::TritonInputRouter)
@Volatile private var stopped = false
private var started = false
private val thread = HandlerThread("steam-controller-ble").apply { start() }
private val handler = Handler(thread.looper)
private val outputReports = mutableMapOf<Int, UUID>()
@@ -36,22 +40,54 @@ class SteamControllerBle(private val context: Context) {
private var outputWritePending = false
private var featureRead = 0L
fun start() = handler.post {
if (running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
fun start(): Unit = synchronized(inputLock) {
if (stopped || started) return@synchronized
started = true
synchronized(slotLock) {
slotOwner = this
connected = false
FakeInputWriter.writeTritonState(SLOT, 0x45, ByteArray(STATE_BYTES))
FakeInputWriter.setTritonConnected(SLOT, false)
}
handler.post {
if (stopped || slotOwner !== this || running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
}
}
fun stop() = handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
private inline fun withSlot(block: () -> Unit) = synchronized(inputLock) {
synchronized(slotLock) {
if (!stopped && slotOwner === this) block()
}
}
fun stop() {
synchronized(inputLock) {
if (stopped) return
stopped = true
routedInput?.close()
routedInput = null
synchronized(slotLock) {
if (slotOwner === this) {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
slotOwner = null
}
}
}
handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
}
}
private fun connect() {
if (!running || gatt != null) return
if (stopped || slotOwner !== this || !running || gatt != null) return
if (SessionState.suspended) {
handler.postDelayed(::connect, RETRY_MS)
return
@@ -84,11 +120,17 @@ class SteamControllerBle(private val context: Context) {
featureRead = 0L
queuedWrites.clear()
outputReports.clear()
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
synchronized(inputLock) {
if (!stopped) {
release()
withSlot {
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
}
}
}
g.disconnect()
g.close()
release()
Log.i(TAG, "disconnected: $why")
listener?.invoke()
}
@@ -119,10 +161,12 @@ class SteamControllerBle(private val context: Context) {
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
FakeInputWriter.setTritonConnected(SLOT, connected)
withSlot {
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
FakeInputWriter.setTritonConnected(SLOT, connected)
}
listener?.invoke()
if (connected) {
refreshLizardMode()
@@ -164,6 +208,7 @@ class SteamControllerBle(private val context: Context) {
}
private fun subscribe(g: BluetoothGatt) {
if (stopped || slotOwner !== this) return
val service = g.getService(SERVICE)
if (service == null) {
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
@@ -207,25 +252,31 @@ class SteamControllerBle(private val context: Context) {
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
}
private fun refreshLizardMode() {
if (!connected) return
private fun refreshLizardMode() = withSlot {
if (!connected) return@withSlot
val msg = ByteArray(FEATURE_BYTES)
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
enqueueOutput(REPORT, msg)
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
}
private fun onState(data: ByteArray) {
if (data.size < STATE_BYTES) return
FakeInputWriter.writeTritonState(SLOT, reportId, data)
private fun onState(data: ByteArray) = withSlot {
if (data.size < STATE_BYTES) return@withSlot
val timing = InputTiming.begin()
FakeInputWriter.writeTritonState(SLOT, reportId, routedInput?.route(data) ?: data)
InputTiming.end(timing)
}
private fun release() {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
private fun release() = synchronized(inputLock) {
if (stopped) return@synchronized
routedInput?.release()
withSlot {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
}
}
private fun pollOutput() {
if (!running || !connected || gatt == null) return
private fun pollOutput() = withSlot {
if (!running || !connected || gatt == null) return@withSlot
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
@@ -239,7 +290,7 @@ class SteamControllerBle(private val context: Context) {
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
}
private fun onFeatureRead(value: ByteArray?) {
private fun onFeatureRead(value: ByteArray?) = withSlot {
outputWritePending = false
if (featureRead != 0L) {
if (value == null) Log.w(TAG, "feature read failed")
@@ -267,14 +318,14 @@ class SteamControllerBle(private val context: Context) {
writeNextOutput()
}
private fun writeNextOutput() {
if (outputWritePending) return
val g = gatt ?: return
val (uuid, value) = queuedWrites.pollFirst() ?: return
private fun writeNextOutput() = withSlot {
if (outputWritePending) return@withSlot
val g = gatt ?: return@withSlot
val (uuid, value) = queuedWrites.pollFirst() ?: return@withSlot
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
if (characteristic == null) {
Log.w(TAG, "BLE output characteristic $uuid disappeared")
return
return@withSlot
}
if (value == null) {
if (g.readCharacteristic(characteristic)) {
@@ -283,7 +334,7 @@ class SteamControllerBle(private val context: Context) {
queuedWrites.addFirst(uuid to null)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
}
return
return@withSlot
}
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@@ -299,6 +350,8 @@ class SteamControllerBle(private val context: Context) {
}
companion object {
private val slotLock = Any()
@Volatile private var slotOwner: SteamControllerBle? = null
private const val TAG = "SteamControllerBle"
private const val SLOT = 0
private const val RETRY_MS = 5_000L
@@ -0,0 +1,93 @@
package com.droiddeck.launcher.input
import kotlin.math.roundToInt
class TritonInputRouter(private val router: InputRouter) {
private val channels = InputControl.entries.map { router.channel(InputRole.PHYSICAL, it) }.toTypedArray()
private var last: ByteArray? = null
private var buffer = ByteArray(45)
private val snapshot: () -> Unit = { last?.let { read(it, false) } }
init { router.beforeProfileChange(snapshot) }
fun route(data: ByteArray): ByteArray = synchronized(router) {
if (data.size < 45) return@synchronized data
last = data
if (channels.all { it.nativeIdentity() }) return@synchronized data
if (buffer.size != data.size) buffer = ByteArray(data.size)
data.copyInto(buffer)
putBits(buffer, bits(data) and CONTROL_BITS.inv())
for (offset in 5..27 step 2) putShort(buffer, offset, 0)
read(data, true)
buffer
}
private fun read(data: ByteArray, emit: Boolean) {
val bits = bits(data)
for (control in InputControl.entries) {
val channel = channels[control.ordinal]
val offset = offset(control)
val pair = control.dimensions == 2
val x = if (offset != 0) axis(data, offset) else if (bits and bit(control) != 0) 1f else 0f
val y = if (pair) axis(data, offset + 2) * if (control.isTrackpad) 1f else -1f else 0f
val contact = control.isTrackpad && bits and bit(control) != 0
if (!emit) { channel.observe(x, y, contact); continue }
val target = channel.update(x, y, contact) ?: continue
val targetOffset = offset(target)
if (targetOffset != 0) {
val value = shortValue(x)
if (target.dimensions == 2) {
val second = shortValue(y * if (target.isTrackpad) 1f else -1f)
val oldX = short(buffer, targetOffset).toLong()
val oldY = short(buffer, targetOffset + 2).toLong()
if (value.toLong() * value + second.toLong() * second >= oldX * oldX + oldY * oldY) {
putShort(buffer, targetOffset, value)
putShort(buffer, targetOffset + 2, second)
}
val touch = if (control.isTrackpad) contact else bits and bit(control) != 0
if (touch) putBits(buffer, bits(buffer) or bit(target))
} else {
if (value >= short(buffer, targetOffset)) putShort(buffer, targetOffset, value)
if (bits and bit(control) != 0) putBits(buffer, bits(buffer) or bit(target))
}
} else if (x > 0.5f) putBits(buffer, bits(buffer) or bit(target))
if (target == InputControl.LEFT_CLICK || target == InputControl.RIGHT_CLICK) {
val pressureOffset = if (target == InputControl.LEFT_CLICK) 21 else 27
val from = if (control == InputControl.LEFT_CLICK) 21 else if (control == InputControl.RIGHT_CLICK) 27 else 0
if (from != 0) putShort(buffer, pressureOffset, short(data, from))
else if (x > 0.5f) putShort(buffer, pressureOffset, 32767)
}
}
}
fun release() = synchronized(router) { channels.forEach { it.release() }; last = null }
fun close() = synchronized(router) { release(); channels.forEach { it.close() }; router.removeSnapshot(snapshot) }
private fun bits(data: ByteArray): Int = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8) or
((data[3].toInt() and 255) shl 16) or ((data[4].toInt() and 255) shl 24)
private fun putBits(data: ByteArray, value: Int) { for (i in 0..3) data[i + 1] = (value ushr (i * 8)).toByte() }
private fun short(data: ByteArray, offset: Int) = ((data[offset].toInt() and 255) or (data[offset + 1].toInt() shl 8)).toShort().toInt()
private fun axis(data: ByteArray, offset: Int): Float = short(data, offset).let { it / if (it < 0) 32768f else 32767f }
private fun shortValue(value: Float) = (value * if (value < 0f) 32768f else 32767f).roundToInt().coerceIn(-32768, 32767)
private fun putShort(data: ByteArray, offset: Int, value: Int) { data[offset] = value.toByte(); data[offset + 1] = (value shr 8).toByte() }
companion object {
private const val CONTROL_BITS = 0x0fffffff
private fun bit(c: InputControl): Int = when (c) {
InputControl.A -> 0x1; InputControl.B -> 0x2; InputControl.X -> 0x4; InputControl.Y -> 0x8
InputControl.QAM -> 0x10; InputControl.R3 -> 0x20; InputControl.START -> 0x40
InputControl.R4 -> 0x80; InputControl.R5 -> 0x100; InputControl.RB -> 0x200
InputControl.DOWN -> 0x400; InputControl.RIGHT -> 0x800; InputControl.LEFT -> 0x1000; InputControl.UP -> 0x2000
InputControl.SELECT -> 0x4000; InputControl.L3 -> 0x8000; InputControl.GUIDE -> 0x10000
InputControl.L4 -> 0x20000; InputControl.L5 -> 0x40000; InputControl.LB -> 0x80000
InputControl.RS -> 0x100000; InputControl.RIGHT_PAD -> 0x200000; InputControl.RIGHT_CLICK -> 0x400000
InputControl.RT -> 0x800000; InputControl.LS -> 0x1000000; InputControl.LEFT_PAD -> 0x2000000
InputControl.LEFT_CLICK -> 0x4000000; InputControl.LT -> 0x8000000
}
private fun offset(c: InputControl): Int = when (c) {
InputControl.LT -> 5; InputControl.RT -> 7; InputControl.LS -> 9; InputControl.RS -> 13
InputControl.LEFT_PAD -> 17; InputControl.RIGHT_PAD -> 23
else -> 0
}
}
}
@@ -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,
@@ -8,8 +8,10 @@ object RuntimeAutoUpdate {
const val MAX_ATTEMPTS = 3
const val RETRY_MS = 10 * 60_000L
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean =
installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
private const val SEEDED = "seeded"
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS, first: Boolean = false): Boolean =
(installed != null || first) && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
fun key(appVersion: Int, release: String) = "$appVersion:$release"
@@ -20,11 +22,17 @@ object RuntimeAutoUpdate {
val key = key(BuildConfig.VERSION_CODE, release)
synchronized(this) {
val attempts = prefs.getInt(key, 0)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
val first = installed == null && !prefs.getBoolean(SEEDED, false)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now, first)) return false
val edit = prefs.edit()
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) }
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") || it == SEEDED }.forEach { edit.remove(it) }
edit.putInt(key, attempts + 1).putLong("$key@at", now).apply()
return true
}
}
fun seed(context: Context) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (!prefs.getBoolean(SEEDED, false)) prefs.edit().putBoolean(SEEDED, true).apply()
}
}
@@ -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)
}
@@ -0,0 +1,245 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import java.io.File
import java.io.IOException
import java.net.URLEncoder
object WowAddonCatalog {
private const val TAG = "WowAddonCatalog"
enum class Sort { POPULAR, UPDATED, NAME, DOWNLOADS }
class Category(val id: String, val name: String)
class Client(val folder: String, val family: String?)
class Query(val text: String = "", val category: String? = null, val client: Client, val sort: Sort = Sort.POPULAR)
class Addon(
val id: String,
val name: String,
val author: String,
val summary: String,
val downloads: Long,
val updated: Long,
val thumb: String?,
)
class Page(val items: List<Addon>, val more: Boolean)
internal class Entry(val addon: Addon, val category: String, val flavors: Set<String>?, val monthly: Long = 0L)
internal class Cat(val id: String, val name: String, val parents: List<String>)
private const val WOWI = "https://api.mmoui.com/v3/game/WOW/"
private const val CURSEFORGE = "https://www.curseforge.com/wow/search?class=addons"
private const val WOWI_FILES = "wowi-filelist.json"
internal const val FRESH_MS = 6L * 60 * 60 * 1000
internal const val RETAIL = "_retail_"
internal const val ERA = "_classic_era_"
internal const val CLASSIC = "_classic_"
internal const val ANNIVERSARY = "_anniversary_"
internal const val FOREVER = "_forever_"
private val TEST_SUFFIX = Regex("_(beta|ptr|xptr)_$")
internal fun flavorOf(version: String): String? {
val major = version.substringBefore('.').trim().toIntOrNull() ?: return null
return when {
major == 1 -> ERA
major == 2 -> ANNIVERSARY
major in 3..5 -> CLASSIC
major >= 10 -> RETAIL
else -> null
}
}
internal fun baseFolder(folder: String): String = folder.replace(TEST_SUFFIX, "_")
internal fun folderFamily(folder: String): String? = when (val base = baseFolder(folder)) {
FOREVER -> RETAIL
RETAIL, ERA, CLASSIC, ANNIVERSARY -> base
else -> null
}
internal fun buildInfoVersion(buildInfo: String, product: String): String? {
val lines = buildInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
if (lines.isEmpty()) return null
val header = lines.first().split('|').map { it.substringBefore('!').trim().lowercase() }
val productAt = header.indexOf("product")
val versionAt = header.indexOf("version")
if (productAt < 0 || versionAt < 0) return null
return lines.drop(1).map { it.split('|') }
.firstOrNull { it.getOrNull(productAt)?.trim().equals(product, ignoreCase = true) }
?.getOrNull(versionAt)?.trim()?.ifEmpty { null }
}
internal fun flavorProduct(flavorInfo: String): String? =
flavorInfo.lineSequence().map { it.trim() }.filter { it.isNotEmpty() && '!' !in it }.firstOrNull()
internal fun clientVersion(flavorDir: File): String? {
val product = runCatching { File(flavorDir, ".flavor.info").readText() }.getOrNull()?.let(::flavorProduct) ?: return null
val info = runCatching { File(flavorDir.parentFile, ".build.info").readText() }.getOrNull() ?: return null
return buildInfoVersion(info, product)
}
internal fun client(folder: String, version: String?): Client =
Client(folder, version?.let(::flavorOf) ?: folderFamily(folder))
fun client(target: WowAddons.Target): Client {
val dir = target.addOns.parentFile?.parentFile
return client(target.flavor, dir?.let(::clientVersion))
}
internal fun flavorMatches(flavors: Set<String>?, family: String?): Boolean =
flavors == null || family == null || family in flavors
internal fun parseWowiFiles(json: String): List<Entry> {
val arr = JSONArray(json)
val out = ArrayList<Entry>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val id = o.optString("UID")
if (id.isEmpty()) continue
val compat = o.optJSONArray("UICompatibility")
val flavors = if (compat == null || compat.length() == 0) null else {
val set = HashSet<String>()
for (k in 0 until compat.length()) compat.optJSONObject(k)?.optString("version")?.let(::flavorOf)?.let(set::add)
set
}
val thumbs = o.optJSONArray("UIIMG_Thumbs")
val thumb = if (thumbs != null && thumbs.length() > 0) thumbs.optString(0).ifEmpty { null } else null
val addon = Addon(
id, o.optString("UIName"), o.optString("UIAuthorName"), o.optString("UIVersion"),
o.optString("UIDownloadTotal").toLongOrNull() ?: 0L, o.optLong("UIDate"), thumb,
)
out.add(Entry(addon, o.optString("UICATID"), flavors, o.optString("UIDownloadMonthly").toLongOrNull() ?: 0L))
}
return out
}
internal fun parseWowiCategories(json: String): List<Cat> {
val arr = JSONArray(json)
val out = ArrayList<Cat>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val parents = o.optJSONArray("UICATParentIDs")
out.add(Cat(o.optString("UICATID"), o.optString("UICATTitle"), (0 until (parents?.length() ?: 0)).map { parents!!.optString(it) }))
}
return out
}
internal fun filterWowi(entries: List<Entry>, cats: List<Cat>, query: Query): List<Addon> {
val parents = cats.associate { it.id to it.parents }
val text = query.text.trim().lowercase()
val list = entries.filter { e ->
flavorMatches(e.flavors, query.client.family) &&
(query.category == null || e.category == query.category || query.category in (parents[e.category] ?: emptyList())) &&
(text.isEmpty() || e.addon.name.lowercase().contains(text) || e.addon.author.lowercase().contains(text))
}
return when (query.sort) {
Sort.POPULAR -> list.sortedWith(compareByDescending<Entry> { it.monthly }.thenByDescending { it.addon.downloads })
Sort.DOWNLOADS -> list.sortedByDescending { it.addon.downloads }
Sort.UPDATED -> list.sortedByDescending { it.addon.updated }
Sort.NAME -> list.sortedBy { it.addon.name.lowercase() }
}.map { it.addon }
}
internal fun pageOf(all: List<Addon>, page: Int, pageSize: Int): Page {
val from = (page * pageSize).coerceAtMost(all.size)
val to = (from + pageSize).coerceAtMost(all.size)
return Page(all.subList(from, to), to < all.size)
}
internal fun cached(file: File, now: Long, fetch: () -> String?): String? {
val kept = if (file.isFile) file.readText().takeIf(::isJsonArray) else null
if (kept != null && isFresh(file, now)) return kept
val body = fetch()?.takeIf(::isJsonArray)
if (body != null) {
val part = File(file.parentFile, "${file.name}.${java.util.UUID.randomUUID()}.part")
runCatching {
file.parentFile?.mkdirs()
part.writeText(body)
part.setLastModified(now)
if (!part.renameTo(file)) Log.w(TAG, "could not keep ${file.name}")
}.onFailure { Log.w(TAG, "could not keep ${file.name}", it) }
part.delete()
return body
}
return kept
}
private fun isJsonArray(body: String): Boolean =
runCatching { org.json.JSONTokener(body).nextValue() }.getOrNull() is JSONArray
private fun isFresh(file: File, now: Long) = file.isFile && now - file.lastModified() < FRESH_MS
private fun cacheFile(context: Context, name: String) = File(File(context.cacheDir, "wowaddons"), name)
private fun cachedGet(context: Context, name: String, url: String): String? =
cached(cacheFile(context, name), System.currentTimeMillis()) { StoreNet.get(url, timeoutMs = 60_000) }
private fun unreachable(context: Context) =
IOException(context.getString(R.string.addons_unreachable, context.getString(R.string.addons_source_wowi)))
fun search(context: Context, query: Query, page: Int, pageSize: Int = 40): Page =
pageOf(filterWowi(wowiEntries(context), wowiCategories(context), query), page, pageSize)
private class Memo(val stamp: Long, val size: Long, val entries: List<Entry>)
@Volatile private var wowiMemo: Memo? = null
private fun wowiEntries(context: Context): List<Entry> {
val file = cacheFile(context, WOWI_FILES)
val memo = wowiMemo
if (memo != null && isFresh(file, System.currentTimeMillis()) && memo.stamp == file.lastModified() && memo.size == file.length()) return memo.entries
val body = cachedGet(context, WOWI_FILES, WOWI + "filelist.json") ?: throw unreachable(context)
return parseWowiFiles(body).also { wowiMemo = Memo(file.lastModified(), file.length(), it) }
}
private fun wowiCategories(context: Context): List<Cat> =
cachedGet(context, "wowi-categories.json", WOWI + "categorylist.json")?.let(::parseWowiCategories) ?: emptyList()
fun categories(context: Context): List<Category> = wowiCategories(context).map { Category(it.id, it.name) }
internal fun parseWowiDetails(json: String): Pair<String, String?>? {
val o = JSONArray(json).optJSONObject(0) ?: return null
val url = o.optString("UIDownload").ifEmpty { return null }
return url to o.optString("UIMD5").ifEmpty { null }
}
internal fun isHttps(url: String) = url.startsWith("https://", ignoreCase = true)
internal fun md5Matches(file: File, expected: String?): Boolean =
expected.isNullOrBlank() || Hashes.md5(file).equals(expected.trim(), ignoreCase = true)
fun download(context: Context, addon: Addon, dest: File, progress: (Int) -> Unit): File {
val body = StoreNet.get(WOWI + "filedetails/${URLEncoder.encode(addon.id, "UTF-8")}.json", timeoutMs = 30_000)
?: throw unreachable(context)
val (url, md5) = parseWowiDetails(body) ?: throw IOException(context.getString(R.string.addons_no_file))
if (!isHttps(url)) throw IOException(context.getString(R.string.addons_no_file))
dest.parentFile?.mkdirs()
dest.delete()
val ok = Downloader.downloadFile(url, dest, false) { f -> if (f >= 0) progress((f * 100).toInt().coerceIn(0, 100)) }
if (!ok || !dest.isFile) {
dest.delete()
throw unreachable(context)
}
if (!md5Matches(dest, md5)) {
dest.delete()
throw IOException(context.getString(R.string.selfinst_checksum_mismatch))
}
progress(100)
return dest
}
internal fun curseForgeUrl(text: String): String =
text.trim().let { if (it.isEmpty()) CURSEFORGE else CURSEFORGE + "&search=" + URLEncoder.encode(it, "UTF-8") }
}
@@ -0,0 +1,435 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AutoComponents
import com.droiddeck.launcher.gpu.GpuInfo
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
object ComponentUpdater {
private const val TAG = "ComponentUpdater"
private const val CHECK_EVERY_MS = 3_600_000L
private const val BOOTED_KEPT = 200
private const val HANDED_BACK = "handedBack"
private const val PINNED_NOTE = "pinnedNote"
internal const val HAND_BACK_FILES = "handBackFiles"
private const val IMPORTED = "imported"
private val lock = Any()
private val io = Any()
data class Status(val line: String, val current: String, val previous: String, val protons: List<String>, val note: String)
private fun file(context: Context) = File(File(context.filesDir, "components").apply { mkdirs() }, "updates.json")
private fun load(context: Context): JSONObject = synchronized(io) {
runCatching { JSONObject(String(AtomicFile(file(context)).readFully(), Charsets.UTF_8)) }.getOrDefault(JSONObject())
}
private fun save(context: Context, state: JSONObject) {
synchronized(io) {
val atomic = AtomicFile(file(context))
val output = runCatching { atomic.startWrite() }.onFailure { Log.w(TAG, "could not save", it) }.getOrNull() ?: return
try {
output.write(state.toString(1).toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
Log.w(TAG, "could not save", error)
}
}
}
private fun Map<String, Map<String, ComponentsManager.Choice>>.choiceOf(p: ComponentsManager.Proton, comp: String): ComponentsManager.Choice? =
this[p.id]?.get(comp)?.takeIf { it.protonVersion == p.version }
private fun Map<String, Map<String, ComponentsManager.Choice>>.fileOf(p: ComponentsManager.Proton, comp: String): String? = choiceOf(p, comp)?.file
private class Target(val comp: String, val p: ComponentsManager.Proton, val line: ComponentUpdates.Line, val ls: JSONObject) {
val key: String get() = line.key
}
private fun JSONObject.obj(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
private fun JSONArray?.strings(): Set<String> = this?.let { a -> (0 until a.length()).map { a.optString(it) }.toSet() } ?: emptySet()
private fun JSONObject.strings(key: String): Set<String> = optJSONArray(key).strings()
private fun JSONObject?.ints(): Map<String, Int> = this?.keys()?.asSequence()?.associateWith { optInt(it) } ?: emptyMap()
private fun JSONObject?.texts(): Map<String, String> = this?.keys()?.asSequence()?.associateWith { optString(it) } ?: emptyMap()
fun settle(context: Context) {
val owned = load(context).optJSONObject("lines")?.let { lines -> lines.keys().asSequence().flatMap { lines.optJSONObject(it).strings("owned") }.toSet() } ?: emptySet()
val own = (runCatching { ComponentsManager.protons(context) }.getOrNull() ?: return).filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
val choices = ComponentsManager.activeChoices(context).filterKeys { it in own }.values.flatMap { it.entries }
for (comp in ComponentsManager.COMPONENTS) {
if (SessionPrefs.componentModeChosen(context, comp)) continue
val manual = choices.any { (c, choice) -> c == comp && ComponentUpdates.manual(choice, null, owned) }
SessionPrefs.setComponentMode(context, comp, if (manual) ComponentUpdates.MANUAL else ComponentUpdates.AUTO)
}
}
private fun managed(context: Context, comp: String): Boolean =
SessionPrefs.componentMode(context, comp) == ComponentUpdates.AUTO && !(comp == "dxvk" && ComponentsManager.hardwareDxvk(context))
private fun logLines(context: Context): Sequence<String> {
val dir = ComponentsManager.steamLogs(context)
return sequenceOf("console_log.previous.txt", "console_log.txt").map { File(dir, it) }.filter { it.isFile }
.flatMap { f -> runCatching { f.readLines() }.getOrDefault(emptyList()).asSequence() }
}
private val STAMP = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)]""")
private fun latestStamp(context: Context): String =
logLines(context).mapNotNull { STAMP.find(it)?.groupValues?.get(1) }.maxOrNull() ?: "0"
private fun lineOf(context: Context, state: JSONObject, p: ComponentsManager.Proton, comp: String): ComponentUpdates.Line? {
if (!ComponentUpdates.follows(p) || !ComponentsManager.supports(p, comp)) return null
if (comp == "fex") return ComponentUpdates.line("fex", false)
val key = "${p.id}|${p.version}"
val flavors = state.obj("flavors").obj(key)
val arm64ec = if (flavors.has(comp)) flavors.optBoolean(comp) else ComponentsManager.arm64ec(context, p, comp)?.also { flavors.put(comp, it) }
return arm64ec?.let { ComponentUpdates.line(comp, it) }
}
fun ensure(context: Context, force: Boolean): List<String> = synchronized(lock) {
if (SessionState.running) return emptyList()
settle(context)
val state = load(context)
val notes = ArrayList<String>()
val found = runCatching { ComponentsManager.protons(context) }.getOrNull()
val protons = found ?: emptyList()
if (found != null) handBack(context, state, found)
judge(context, state, protons, notes)
ComponentsManager.pinnedAutoDxvk(context)?.let { pinned ->
runCatching { ComponentsManager.ensureForcedPackages(context) }
.onSuccess { if (it != null) Log.i(TAG, it) }.onFailure { Log.w(TAG, "pinned DXVK", it) }
val followers = protons.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, "dxvk") }.map { it.id to it.version }
if (pinnedHeld(pinned, followers, ComponentsManager.activeChoices(context), pinnedBase())) state.remove(PINNED_NOTE)
else state.put(PINNED_NOTE, context.getString(R.string.comp_auto_install_failed, pinned.removeSuffix(".wcp")))
}
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
if (wanted.isEmpty() || protons.isEmpty()) {
save(context, state)
return notes
}
try {
dropRemoved(state, protons)
val checked = due && check(context, state, protons, wanted, force, notes)
apply(context, state, protons, wanted, notes)
prune(context, state)
if (checked) state.put("checkedAt", System.currentTimeMillis())
} finally {
save(context, state)
}
notes
}
private fun check(
context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, force: Boolean, notes: MutableList<String>,
): Boolean {
if (!AutoComponents.isOnline(context)) {
state.put("note", context.getString(R.string.comp_auto_waits_network))
return false
}
val catalog = runCatching { ComponentsManager.catalog(context, true).items }
.onFailure { Log.w(TAG, "catalog", it) }.getOrDefault(emptyList())
if (catalog.none { it.release == ComponentsManager.DD_NIGHTLY }) {
state.put("note", context.getString(R.string.comp_auto_check_failed))
return false
}
val holdBack = !force && AutoComponents.constrained(context)
val lines = state.obj("lines")
var complete = true
state.remove("note")
for (comp in wanted) for (p in protons) {
val line = lineOf(context, state, p, comp) ?: continue
val ls = lines.obj(line.key)
if (rollingBack(ls)) {
complete = false
continue
}
val current = ls.optString("current")
val missing = current.isNotEmpty() && !ComponentsManager.isStored(context, current)
val relisted = if (missing) catalog.firstOrNull { it.file == current } else null
if (relisted != null) {
if (holdBack) {
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
complete = false
continue
}
val got = runCatching { ComponentsManager.download(context, relisted) {} }
if (got.isFailure) {
Log.w(TAG, "download ${relisted.file} again", got.exceptionOrNull())
state.put("note", context.getString(R.string.comp_auto_check_failed))
complete = false
continue
}
own(state, ls, current)
}
val target = ComponentUpdates.pick(catalog, line, ls.strings("bad"))
if (target == null || current == target.file) continue
val stored = ComponentsManager.isStored(context, target.file)
if (!stored) {
if (holdBack) {
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
complete = false
continue
}
val got = runCatching { ComponentsManager.download(context, target) {} }
if (got.isFailure) {
Log.w(TAG, "download ${target.file}", got.exceptionOrNull())
state.put("note", context.getString(R.string.comp_auto_check_failed))
complete = false
continue
}
}
val stocks = protons.filter { lineOf(context, state, it, comp)?.key == line.key }.map { ComponentsManager.arm64x(context, it, comp) }
if (ComponentUpdates.needsArm64x(line, stocks) && !ComponentsManager.packageArm64x(context, target.file, comp)) {
Log.w(TAG, "${target.file} is not ARM64X like ${p.name}'s own ${line.key}; skipped")
ls.put("bad", JSONArray(ls.strings("bad") + target.file))
if (!stored) runCatching { ComponentsManager.deletePackage(context, target.file) }
continue
}
ls.put("previous", if (missing && relisted == null) ComponentUpdates.ORIGINAL else current.ifEmpty { ComponentUpdates.ORIGINAL })
.put("current", target.file).put("since", latestStamp(context)).put("failed", JSONObject())
if (!stored) own(state, ls, target.file)
ls.remove("note")
notes += context.getString(R.string.comp_auto_updated, ComponentsManager.LABEL[comp], target.file.removeSuffix(".wcp"))
}
return complete
}
private fun apply(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, notes: MutableList<String>) {
val lines = state.obj("lines")
val auto = placed(state)
val choices = ComponentsManager.activeChoices(context)
val targets = wanted.flatMap { comp ->
protons.mapNotNull { p -> lineOf(context, state, p, comp)?.let { line -> lines.optJSONObject(line.key)?.let { Target(comp, p, line, it) } } }
}.filterNot { t -> ComponentUpdates.manual(choices.choiceOf(t.p, t.comp), t.ls.optJSONObject("applied")?.optString(t.p.id), auto, t.ls.optString("current")) }
for (t in targets) {
val from = t.ls.optJSONObject("restore")?.optString(t.p.id).orEmpty()
val current = t.ls.optString("current")
if (from.isNotEmpty()) {
if (choices.fileOf(t.p, t.comp) == from) runCatching { ComponentsManager.restore(context, t.p.id, t.comp, ComponentsManager.safeName(t.p.version)) }
.onFailure { Log.w(TAG, "restore ${t.p.name} ${t.key}", it) }
} else if (current.isNotEmpty() && choices.fileOf(t.p, t.comp) != current) {
if (ComponentUpdates.needsArm64x(t.line, listOf(ComponentsManager.arm64x(context, t.p, t.comp))) &&
!ComponentsManager.packageArm64x(context, current, t.comp)) continue
runCatching { ComponentsManager.swap(context, t.p.id, current) }
.onFailure { Log.w(TAG, "${t.p.name} ${t.key}", it) }
}
}
val after = ComponentsManager.activeChoices(context)
val unfinished = HashSet<String>()
for (t in targets) {
val restore = t.ls.optJSONObject("restore")
val from = restore?.optString(t.p.id).orEmpty()
val current = t.ls.optString("current")
if (from.isNotEmpty()) {
if (after.fileOf(t.p, t.comp) == from) unfinished += t.key
else {
restore?.remove(t.p.id)
if (after.choiceOf(t.p, t.comp)?.file?.startsWith("original ") != false) t.ls.optJSONObject("applied")?.remove(t.p.id)
}
} else if (current.isNotEmpty()) {
if (after.fileOf(t.p, t.comp) == current) t.ls.obj("applied").put(t.p.id, current) else unfinished += t.key
}
}
for (key in lines.keys().asSequence().toList()) {
val ls = lines.optJSONObject(key) ?: continue
val pending = ls.optString("pendingNote")
if (pending.isEmpty() || key in unfinished || (ls.optJSONObject("restore")?.length() ?: 0) > 0) continue
ls.remove("pendingNote")
ls.put("note", pending)
notes += pending
}
}
private fun own(state: JSONObject, ls: JSONObject, file: String) {
ls.put("owned", JSONArray(ls.strings("owned") + file))
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) - file))
}
fun importPackage(context: Context, source: File, name: String): ComponentsManager.Package = synchronized(lock) {
ComponentsManager.importPackage(context, source, name).also { disown(context, it.file) }
}
fun disown(context: Context, file: String) = synchronized(lock) {
val state = load(context)
state.optJSONObject("lines")?.let { lines ->
lines.keys().asSequence().mapNotNull { lines.optJSONObject(it) }.filter { file in it.strings("owned") }
.forEach { it.put("owned", JSONArray(it.strings("owned") - file)) }
}
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) + file))
save(context, state)
}
internal fun placed(state: JSONObject): Set<String> {
val remembered = state.strings(HAND_BACK_FILES)
val lines = state.optJSONObject("lines") ?: return remembered - state.strings(IMPORTED)
return remembered + lines.keys().asSequence().flatMap { k ->
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
(ls.strings("owned") + ls.strings("bad") + ls.optString("current") + ls.optString("previous")).asSequence()
}.filter { it.isNotEmpty() }.toSet() - state.strings(IMPORTED)
}
private fun handBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>) {
if (state.optBoolean(HANDED_BACK)) return
val placed = placed(state)
state.put(HAND_BACK_FILES, JSONArray(placed.sorted()))
val own = protons.filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
runCatching { ComponentsManager.dropQueuedPackages(context, placed) { it in own || ComponentUpdates.ownId(it) } }.onFailure { Log.w(TAG, "drop queued Auto builds", it) }.getOrNull() ?: return
val strays = ComponentUpdates.strays(protons, ComponentsManager.activeChoices(context), placed)
for ((p, comp) in strays) {
runCatching { ComponentsManager.restoreUnlessQueued(context, p.id, comp, ComponentsManager.safeName(p.version)) }
.onFailure { Log.w(TAG, "hand back ${p.name} $comp", it) }
}
val after = ComponentsManager.activeChoices(context)
if (ComponentUpdates.strays(protons, after, placed).isEmpty() && !ComponentUpdates.unseen(protons, after, placed)) {
state.put(HANDED_BACK, true)
state.remove(HAND_BACK_FILES)
}
}
internal fun dropRemoved(state: JSONObject, protons: List<ComponentsManager.Proton>) {
val installed = protons.mapTo(HashSet()) { it.id }
val lines = state.optJSONObject("lines") ?: return
for (key in lines.keys().asSequence().toList()) {
val restore = lines.optJSONObject(key)?.optJSONObject("restore") ?: continue
for (id in restore.keys().asSequence().toList()) if (id !in installed) restore.remove(id)
}
}
internal fun rollingBack(ls: JSONObject): Boolean =
(ls.optJSONObject("restore")?.length() ?: 0) > 0 || ls.optString("pendingNote").isNotEmpty()
private fun judge(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, notes: MutableList<String>) {
val mark = state.optString("logMark")
val runs = ComponentUpdates.launches(logLines(context), mark)
if (runs.isEmpty()) return
state.put("logMark", runs.maxOf { it.start })
val byTool = protons.associateBy { it.dir.name }
val lines = state.obj("lines")
for (comp in ComponentsManager.COMPONENTS) {
val onLine = HashMap<String, MutableList<ComponentUpdates.Launch>>()
for (run in runs) {
val p = byTool[run.tool] ?: continue
if (ComponentsManager.gameComponents(context, run.app).containsKey(comp)) continue
val line = lineOf(context, state, p, comp) ?: continue
onLine.getOrPut(line.key) { ArrayList() } += run
}
for ((key, list) in onLine) {
val ls = lines.obj(key)
val verdict = ComponentUpdates.judge(ComponentUpdates.Trial(
ls.optString("current"), ls.optString("previous"), ls.optString("since"),
ls.optJSONObject("booted").texts(), ls.optJSONObject("failed").ints(),
), list)
ls.put("booted", JSONObject(verdict.booted.entries.toList().takeLast(BOOTED_KEPT).associate { it.key to it.value } as Map<*, *>))
ls.put("failed", JSONObject(verdict.failed as Map<*, *>))
if (verdict.rollBack && managed(context, comp)) rollBack(context, state, protons, comp, key, notes)
}
}
}
private fun rollBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, comp: String, key: String, notes: MutableList<String>) {
val ls = state.obj("lines").obj(key)
val bad = ls.optString("current")
val onLine = protons.filter { lineOf(context, state, it, comp)?.key == key }
val line = ComponentUpdates.LINES.first { it.key == key }
val back = ls.optString("previous").takeIf { prev -> prev == ComponentUpdates.ORIGINAL || (ComponentsManager.isStored(context, prev) &&
(!ComponentUpdates.needsArm64x(line, onLine.map { ComponentsManager.arm64x(context, it, comp) }) || ComponentsManager.packageArm64x(context, prev, comp))) }
?: ComponentUpdates.ORIGINAL
val choices = ComponentsManager.activeChoices(context)
val targets = onLine.filter { choices.fileOf(it, comp) == bad }
for (p in targets) {
runCatching {
if (back == ComponentUpdates.ORIGINAL) ComponentsManager.restore(context, p.id, comp, ComponentsManager.safeName(p.version))
else ComponentsManager.swap(context, p.id, back)
}.onFailure { Log.w(TAG, "roll back ${p.name} $key", it) }
}
val after = ComponentsManager.activeChoices(context)
val unfinished = targets.filter { after.fileOf(it, comp) == bad }
for (p in targets - unfinished.toSet()) {
if (back == ComponentUpdates.ORIGINAL) ls.optJSONObject("applied")?.remove(p.id)
else if (after.fileOf(p, comp) == back) ls.obj("applied").put(p.id, back)
}
ls.put("bad", JSONArray(ls.strings("bad") + bad)).put("current", back).put("previous", ComponentUpdates.ORIGINAL)
.put("since", latestStamp(context)).put("failed", JSONObject())
if (back == ComponentUpdates.ORIGINAL) ls.obj("restore").let { restore -> unfinished.forEach { restore.put(it.id, bad) } }
val label = ComponentsManager.LABEL[comp]
val shown = back.ifEmpty { context.getString(R.string.comp_tag_original) }.removeSuffix(".wcp")
val note = context.getString(R.string.comp_auto_rolled_back, label, bad.removeSuffix(".wcp"), shown)
Log.w(TAG, note)
if (unfinished.isNotEmpty()) {
ls.put("pendingNote", note)
return
}
ls.remove("pendingNote")
ls.put("note", note)
notes += note
prune(context, state)
}
private fun prune(context: Context, state: JSONObject) {
val lines = state.obj("lines")
val selected = ComponentsManager.selectedPackages(context)
val keep = lines.keys().asSequence().flatMap { k ->
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
ComponentUpdates.keep(ls.optString("current"), ls.optString("previous"), selected).asSequence()
}.toSet()
val stored = ComponentsManager.storedFiles(context)
val followed = ComponentUpdates.LINES.filter { managed(context, it.comp) }
for (k in lines.keys().asSequence().toList()) {
val ls = lines.optJSONObject(k) ?: continue
val owned = ls.strings("owned")
val stale = ComponentUpdates.stale(followed.firstOrNull { it.key == k }, owned, stored, keep, ls.optString("current"), ls.strings("bad").toSet())
val gone = stale.filter { runCatching { ComponentsManager.deleteUnused(context, it) }.getOrDefault(false) }
ls.put("owned", JSONArray(owned - gone.toSet()))
}
}
internal fun pinnedHeld(pinned: String, followers: List<Pair<String, String>>, choices: Map<String, Map<String, ComponentsManager.Choice>>, base: String?): Boolean =
followers.all { (id, version) ->
val choice = choices[id]?.get("dxvk")
ownChoice(choice, version, pinned, base) || choice?.let { it.file == ComponentsManager.safeName(pinned) && it.protonVersion == version } == true
}
internal fun ownChoice(choice: ComponentsManager.Choice?, version: String, pinned: String, base: String?): Boolean =
choice != null && !choice.auto && choice.protonVersion == version && choice.file != ComponentsManager.safeName(pinned) &&
(base == null || choice.file != ComponentsManager.safeName(base))
private fun pinnedBase(): String? = ComponentsManager.forcedDxvk(GpuInfo.detect())?.file
fun status(context: Context, comp: String): List<Status> {
val pinned = if (comp == "dxvk") ComponentsManager.pinnedAutoDxvk(context) else null
if (pinned != null) {
val on = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
.filter { ComponentUpdates.follows(it) && ComponentsManager.supports(it, comp) }
if (on.isEmpty()) return emptyList()
val choices = ComponentsManager.activeChoices(context)
val base = pinnedBase()
val (own, auto) = on.partition { ownChoice(choices[it.id]?.get(comp), it.version, pinned, base) }
val held = pinnedHeld(pinned, auto.map { it.id to it.version }, choices, base)
val pinnedLine = if (auto.isEmpty()) emptyList()
else listOf(Status(comp, pinned, pinned, auto.map { it.name }, if (held) "" else load(context).optString(PINNED_NOTE)))
return pinnedLine + own.groupBy { choices.getValue(it.id).getValue(comp).file }.map { (file, ps) -> Status(comp, file, file, ps.map { it.name }, "") }
}
val state = load(context)
val lines = state.optJSONObject("lines") ?: JSONObject()
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
val byLine = protons.groupBy { lineOf(context, state, it, comp)?.key }
return ComponentUpdates.LINES.filter { it.comp == comp }.mapNotNull { line ->
val on = byLine[line.key].orEmpty()
val ls = lines.optJSONObject(line.key)
if (on.isEmpty() && ls == null) return@mapNotNull null
val note = ls?.optString("note").orEmpty().ifEmpty { state.optString("note") }
Status(line.key, ls?.optString("current").orEmpty(), ls?.optString("previous").orEmpty(), on.map { it.name }, note)
}
}
}
@@ -0,0 +1,167 @@
package com.droiddeck.launcher.session
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.text.SimpleDateFormat
import java.util.Locale
object ComponentUpdates {
const val AUTO = "auto"
const val MANUAL = "manual"
const val ORIGINAL = ""
const val BOOT_SECONDS = 30L
const val BOOTED_SECONDS = 60L
const val FAILED_BOOTS_BEFORE_ROLLBACK = 2
data class Line(val key: String, val comp: String, val prefix: String)
val LINES = listOf(
Line("dxvk", "dxvk", "DD-dxvk-"),
Line("dxvk-arm64ec", "dxvk", "DD-dxvk-arm64ec-"),
Line("vkd3d", "vkd3d", "DD-vkd3d-proton-"),
Line("vkd3d-arm64ec", "vkd3d", "DD-vkd3d-proton-arm64ec-"),
Line("fex", "fex", "DD-FEXCore-"),
)
private val OWN_PREFIX = ProtonExtras.tools.first { it.id == ProtonExtras.OWN }.prefix
fun follows(p: ComponentsManager.Proton): Boolean = !p.valve && p.dir.name.startsWith(OWN_PREFIX)
fun ownId(protonId: String): Boolean = protonId.startsWith(ComponentsManager.safeName(OWN_PREFIX))
fun strays(
protons: List<ComponentsManager.Proton>,
choices: Map<String, Map<String, ComponentsManager.Choice>>,
placed: Set<String>,
): List<Pair<ComponentsManager.Proton, String>> =
protons.filterNot(::follows).flatMap { p ->
choices[p.id].orEmpty().filter { (_, c) -> !c.auto && c.protonVersion == p.version && c.file in placed }.keys.map { p to it }
}
fun unseen(
protons: List<ComponentsManager.Proton>,
choices: Map<String, Map<String, ComponentsManager.Choice>>,
placed: Set<String>,
): Boolean {
val found = protons.mapTo(HashSet()) { it.id }
return choices.any { (id, comps) -> id !in found && comps.values.any { !it.auto && it.file in placed } }
}
fun line(comp: String, arm64ec: Boolean): Line = LINES.first {
it.comp == comp && (comp == "fex" || it.key.endsWith("-arm64ec") == arm64ec)
}
fun pick(items: List<ComponentsManager.CatalogItem>, line: Line, bad: Set<String>): ComponentsManager.CatalogItem? {
val newestBad = bad.filter { belongs(it, line) }.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a, b) }
return items.filter { it.release == ComponentsManager.DD_NIGHTLY && it.comp == line.comp && belongs(it.file, line) }
.filter { newestBad == null || ProtonExtras.compareVersions(it.file, newestBad) > 0 }
.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }
}
fun belongs(file: String, line: Line): Boolean =
file.startsWith(line.prefix) && file.removePrefix(line.prefix).firstOrNull()?.isDigit() == true
fun arm64ec(pe: ByteArray): Boolean? = runCatching {
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
val header = b.getInt(0x3c)
if (b.getInt(header) != 0x4550) return null
val sections = b.getShort(header + 6).toInt() and 0xffff
val optionalSize = b.getShort(header + 20).toInt() and 0xffff
val optional = header + 24
if ((b.getShort(optional).toInt() and 0xffff) != 0x20b) return false
val loadConfigRva = b.getInt(optional + 112 + 10 * 8)
if (loadConfigRva == 0) return false
val table = optional + optionalSize
var offset = -1
for (i in 0 until sections) {
val s = table + i * 40
val size = b.getInt(s + 8)
val va = b.getInt(s + 12)
if (loadConfigRva >= va && loadConfigRva < va + maxOf(size, 1)) offset = b.getInt(s + 20) + loadConfigRva - va
}
if (offset < 0) return null
b.getInt(offset) >= 0xC8 + 8 && b.getLong(offset + 0xC8) != 0L
}.getOrNull()
fun arm64x(pe: ByteArray): Boolean = runCatching {
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
val header = b.getInt(0x3c)
b.getInt(header) == 0x4550 && (b.getShort(header + 4).toInt() and 0xffff) == 0xaa64 && arm64ec(pe) == true
}.getOrDefault(false)
fun allArm64x(dlls: Map<String, ByteArray>, owned: Set<String>): Boolean {
val graphics = dlls.filterKeys { it.lowercase() in owned }
return graphics.isNotEmpty() && graphics.values.all(::arm64x)
}
fun needsArm64x(line: Line, stocks: List<Boolean?>): Boolean =
line.key.endsWith("-arm64ec") && stocks.any { it != false }
data class Launch(val app: String, val tool: String, val start: String, val seconds: Long)
private val PROCESS = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)] Game process (added|removed)\s*: AppID (\d+)(?: "(.*))?""")
private val TOOL = Regex("""compatibilitytools\.d/([^/'"]+)['"]?/|steamapps/common/([^/'"]+)['"]?/proton""")
fun launches(log: Sequence<String>, after: String): List<Launch> {
val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
val open = HashMap<String, Pair<String, String>>()
val out = ArrayList<Launch>()
for (line in log) {
val m = PROCESS.find(line) ?: continue
val (time, what, app) = m.destructured
if (time <= after) continue
if (what == "added") {
val tool = TOOL.find(m.groupValues[4])?.let { t -> t.groupValues[1].ifEmpty { t.groupValues[2] } } ?: continue
open[app] = time to tool
} else {
val (start, tool) = open.remove(app) ?: continue
val seconds = runCatching { (format.parse(time)!!.time - format.parse(start)!!.time) / 1000 }.getOrNull() ?: continue
out += Launch(app, tool, start, seconds)
}
}
return out
}
data class Trial(
val current: String,
val previous: String,
val since: String,
val booted: Map<String, String>,
val failed: Map<String, Int>,
)
data class Verdict(val booted: Map<String, String>, val failed: Map<String, Int>, val rollBack: Boolean)
fun judge(trial: Trial, runs: List<Launch>): Verdict {
val booted = trial.booted.toMutableMap()
val failed = trial.failed.toMutableMap()
var rollBack = false
for (run in runs) {
val build = if (trial.since.isNotEmpty() && run.start > trial.since) trial.current else trial.previous
if (run.seconds >= BOOTED_SECONDS) {
booted[run.app] = build
if (build == trial.current) failed.remove(run.app)
} else if (run.seconds < BOOT_SECONDS && build == trial.current && trial.current != trial.previous &&
booted[run.app] == trial.previous) {
val n = (failed[run.app] ?: 0) + 1
failed[run.app] = n
if (n >= FAILED_BOOTS_BEFORE_ROLLBACK) rollBack = true
}
}
return Verdict(booted, failed, rollBack)
}
fun keep(current: String, previous: String, selected: Set<String>): Set<String> =
(setOf(current, previous) + selected) - ORIGINAL
fun manual(choice: ComponentsManager.Choice?, applied: String?, auto: Set<String>, current: String? = null): Boolean =
choice != null && !choice.auto && !choice.file.startsWith("original ") && choice.file != current &&
(if (applied.isNullOrEmpty()) choice.file !in auto else choice.file != applied)
fun stale(line: Line?, owned: Set<String>, stored: List<String>, keep: Set<String>, current: String, bad: Set<String> = emptySet()): List<String> {
val ranked = line?.takeIf { belongs(current, it) }
fun newer(file: String) = ranked != null && belongs(file, ranked) && ProtonExtras.compareVersions(file, current) > 0
return (owned + stored.filter { ranked != null && belongs(it, ranked) })
.filter { it !in keep && ((it in bad && it in owned) || !newer(it)) }.distinct()
}
}
@@ -84,6 +84,7 @@ object ComponentsManager {
private fun root(context: Context) = LinuxRuntime.rootDir(context)
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
fun steamLogs(context: Context) = File(steam(context), "logs")
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
@@ -699,6 +700,12 @@ object ComponentsManager {
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
}
fun restoreUnlessQueued(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
if (loadState(context).optJSONObject("queued")?.optJSONObject(protonId)?.has(comp) == true) return false
restore(context, protonId, comp, protonVersion)
true
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
@@ -749,6 +756,25 @@ object ComponentsManager {
saveState(context, state)
}
fun dropQueuedPackages(context: Context, files: Set<String>, keep: (String) -> Boolean): Int = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
var dropped = 0
for (pid in queued.keys().asSequence().toList()) {
if (keep(pid)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("kind") != "package" || entry.optBoolean("auto") || entry.optString("file") !in files) continue
comps.remove(comp)
dropped++
}
if (comps.length() == 0) queued.remove(pid)
}
if (dropped > 0) saveState(context, state)
dropped
}
class StoredPackage(val pkg: Package, val inUse: Boolean)
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
@@ -756,7 +782,11 @@ object ComponentsManager {
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
internal fun liveOnAny(key: String, entry: JSONObject, installs: List<Proton>?): Boolean =
installs?.any { liveSelection(key, entry, it) } == true
internal fun packagesInUse(state: JSONObject, installed: List<Proton>): Set<String> {
val byId = installed.groupBy { it.id }
val names = HashSet<String>()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
@@ -764,7 +794,7 @@ object ComponentsManager {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
if (liveOnAny(key, entry, byId[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
}
}
}
@@ -786,7 +816,7 @@ object ComponentsManager {
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val used = packagesInUse(state, installed)
val used = packagesInUse(state, protons(context))
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
@@ -813,15 +843,15 @@ object ComponentsManager {
}
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val installed = protons(context).groupBy { it.id }
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
check(entry.optString("file") != name || !liveOnAny(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid)
}
}
}
@@ -911,7 +941,7 @@ object ComponentsManager {
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val installed = protons(context).groupBy { it.id }
var changed = false
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
@@ -920,7 +950,7 @@ object ComponentsManager {
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("file") != name) continue
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
check(!liveOnAny(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid) }
comps.remove(comp)
changed = true
}
@@ -967,9 +997,16 @@ object ComponentsManager {
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
val previous = if (cache.isFile) runCatching { JSONObject(cache.readText()).optJSONArray("items") }.getOrNull() else null
var fetched = 0
for ((repo, tag) in SOURCES) {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag")
val assets = body?.let { runCatching { JSONObject(it).optJSONArray("assets") }.getOrNull() }
if (assets == null) {
keepSource(previous, tag, items)
continue
}
fetched++
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
@@ -980,13 +1017,18 @@ object ComponentsManager {
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (fetched == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
internal fun keepSource(previous: JSONArray?, tag: String, into: JSONArray) {
previous ?: return
for (i in 0 until previous.length()) previous.optJSONObject(i)?.takeIf { it.optString("release") == tag }?.let(into::put)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
@@ -1023,14 +1065,110 @@ object ComponentsManager {
val file: String,
val release: String,
val models: Set<Int> = emptySet(),
val autoFile: String? = null,
val autoRelease: String? = null,
) {
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
fun autoRule(): ForcedPackage? =
if (autoFile != null && autoRelease != null) copy(file = autoFile, release = autoRelease, autoFile = null, autoRelease = null) else null
}
internal val FORCED_PACKAGES = listOf(
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux",
autoFile = "dxvk-gplasync-2.7.1-1-linux.wcp", autoRelease = "Dxvk-gplasync-Linux"),
)
internal fun autoDxvk(gpu: GpuInfo): ForcedPackage? = forcedDxvk(gpu)?.autoRule()
internal fun forcedDxvk(gpu: GpuInfo): ForcedPackage? = FORCED_PACKAGES.firstOrNull { it.comp == "dxvk" && it.matches(gpu) }
private val ARM64EC_PROBE = mapOf("dxvk" to "d3d11.dll", "vkd3d" to "d3d12.dll")
fun arm64ec(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64ec)
fun arm64x(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64x)
fun packageArm64x(context: Context, file: String, comp: String): Boolean {
val dir = "${COMP_DIR[comp] ?: return false}/aarch64-windows/"
val f = File(packagesDir(context), safeName(file)).takeIf { it.isFile } ?: return false
val dlls = runCatching {
readWcp(f) { tar ->
val found = HashMap<String, ByteArray>()
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (!e.isDirectory && rel.startsWith(dir) && '/' !in rel.removePrefix(dir)) found[rel.removePrefix(dir)] = tar.readBytes()
}
found
}
}.getOrNull() ?: return false
return ComponentUpdates.allArm64x(dlls, COMP_DLLS.getValue(comp))
}
private fun readWcpEntry(f: File, rel: String): ByteArray? = readWcp(f) { tar ->
var found: ByteArray? = null
while (found == null) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == rel) found = tar.readBytes()
}
found
}
private fun stockProbe(context: Context, p: Proton, comp: String): ByteArray? {
val rel = "${COMP_DIR[comp] ?: return null}/aarch64-windows/${ARM64EC_PROBE.getValue(comp)}"
val original = synchronized(lock) {
val swapped = loadState(context).optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(comp)
?.let { liveSelection("active", it, p) } == true
if (!swapped) return File(p.dir, rel).takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() }
originalFile(context, p, comp).takeIf { it.isFile } ?: return null
}
return runCatching { readWcpEntry(original, rel) }.getOrNull()
}
fun supports(p: Proton, comp: String): Boolean =
if (comp == "fex") File(p.dir, FEX_FILES.first()).isFile else File(p.dir, "${COMP_DIR.getValue(comp)}/aarch64-windows").isDirectory
fun hardwareDxvk(context: Context): Boolean =
runtimeManaged(context, "dxvk") || FORCED_PACKAGES.any { it.comp == "dxvk" && it.matches(GpuInfo.detect()) } ||
(com.droiddeck.launcher.core.DeviceSupport.vkBridge(context) && !SessionPrefs.bridgeProtonDxvk(context) &&
bridgeDxvk(AppVulkan.detect(context)) != null)
fun autoDxvkHeld(context: Context): Boolean =
hardwareDxvk(context) && (runtimeManaged(context, "dxvk") || autoDxvk(GpuInfo.detect()) == null)
fun pinnedAutoDxvk(context: Context): String? =
if (SessionPrefs.componentMode(context, "dxvk") == ComponentUpdates.AUTO) autoDxvk(GpuInfo.detect())?.file else null
data class Choice(val file: String, val auto: Boolean, val protonVersion: String)
fun activeChoices(context: Context): Map<String, Map<String, Choice>> = synchronized(lock) {
val active = loadState(context).optJSONObject("active") ?: return emptyMap()
active.keys().asSequence().toList().associateWith { pid ->
val comps = active.optJSONObject(pid) ?: JSONObject()
comps.keys().asSequence().toList().mapNotNull { c ->
comps.optJSONObject(c)?.let { c to Choice(it.optString("file"), it.optBoolean("auto"), it.optString("protonVersion")) }
}.toMap()
}
}
fun selectedPackages(context: Context): Set<String> = synchronized(lock) {
packagesInUse(loadState(context), protons(context))
}
fun deleteUnused(context: Context, file: String): Boolean = synchronized(lock) {
val name = safeName(file)
val f = File(packagesDir(context), name)
if (!f.isFile || name in packagesInUse(loadState(context), protons(context))) return false
deletePackage(context, name)
true
}
fun storedFiles(context: Context): List<String> =
packagesDir(context).listFiles()?.filter { it.isFile && it.name.endsWith(".wcp") }?.map { it.name } ?: emptyList()
fun isStored(context: Context, file: String): Boolean = File(packagesDir(context), safeName(file)).isFile
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
activeFile.isNullOrEmpty() || !protonVersionMatches
@@ -1046,6 +1184,14 @@ object ComponentsManager {
internal const val DXVK_FOR_VULKAN_1_1 = "dxvk-1.10.3-linux.wcp"
internal const val DXVK_FOR_VULKAN_1_3_NO_INT64 = "dxvk-2.6.2-linux.wcp"
private data class ForcedRule(
val pkg: ForcedPackage,
val auto: Boolean,
val covers: (Proton) -> Boolean,
val replaces: String? = null,
val label: String? = null,
)
internal fun autoDxvkUndoWanted(bridgeSession: Boolean, keepProtonDxvk: Boolean, vulkan: AppVulkan?, bridgeRule: ForcedPackage?): Boolean =
!bridgeSession || keepProtonDxvk || (vulkan != null && bridgeRule == null)
@@ -1078,6 +1224,7 @@ object ComponentsManager {
undoAuto = { id, comp -> undoAutoChoice(context, id, comp) },
keepProtonDxvk = SessionPrefs.bridgeProtonDxvk(context),
autoPossible = !com.droiddeck.launcher.core.DeviceSupport.adreno(),
dxvkAuto = pinnedAutoDxvk(context) != null,
) }
fun settleAutoDxvk(context: Context) {
@@ -1117,10 +1264,16 @@ object ComponentsManager {
undoAuto: (protonId: String, comp: String) -> String = { _, _ -> "" },
keepProtonDxvk: Boolean = false,
autoPossible: Boolean = true,
dxvkAuto: Boolean = false,
follows: (Proton) -> Boolean = ComponentUpdates::follows,
): String? {
val chooseDxvk = bridgeSession && !keepProtonDxvk
val bridgeRule = if (chooseDxvk) bridgeDxvk(vulkan) else null
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }.map { it to false } + listOfNotNull(bridgeRule?.let { it to true })
val family = FORCED_PACKAGES.filter { it.matches(gpu) }
val pinned = if (dxvkAuto) family.mapNotNull { base -> base.autoRule()?.let { base to it } } else emptyList()
val rules = family.map { base -> ForcedRule(base, false, if (pinned.any { it.first == base }) { p -> !follows(p) } else { _ -> true }) } +
pinned.map { (base, rule) -> ForcedRule(rule, true, follows, replaces = base.file) } +
listOfNotNull(bridgeRule?.let { ForcedRule(it, true, { true }, label = "${vulkan?.label}: DXVK ${it.file}") })
if (rules.isEmpty() && !autoPossible) return null
val installed = protons(context)
val lines = mutableListOf<String>()
@@ -1134,12 +1287,13 @@ object ComponentsManager {
if (autoChoiceStale(entry, null)) lines.add(each(p) { undoAuto(p.id, "dxvk") })
}
}
for ((rule, auto) in rules) {
for ((rule, auto, covers, replaces, label) in rules) {
val state = loadState(context)
val targets = installed.filter { p ->
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version) ||
(auto && autoChoiceStale(active, rule.file))
val current = active?.optString("protonVersion") == p.version
covers(p) && (forcedPackageWanted(active?.optString("file"), current) ||
(auto && autoChoiceStale(active, rule.file)) || (replaces != null && current && active?.optString("file") == replaces))
}
if (targets.isEmpty()) continue
val missing = missingPackage(context, rule, lookup, fetch)
@@ -1147,7 +1301,7 @@ object ComponentsManager {
lines.add(missing)
continue
}
if (auto) lines.add("${vulkan?.label}: DXVK ${rule.file}")
if (label != null) lines.add(label)
lines.add(targets.joinToString("; ") { p -> if (auto) each(p) { applyAuto(p.id, rule.file) } else apply(p.id, rule.file) })
}
return lines.filter { it.isNotEmpty() }.joinToString("; ").ifEmpty { null }
@@ -57,8 +57,10 @@ object GameStorage {
return pref.isNotEmpty() && pref != SessionPrefs.GAME_STORAGE_OFF && !attached(File(pref))
}
internal fun attached(path: File): Boolean =
attached(path, runCatching { Environment.getExternalStorageState(path) }.getOrNull())
internal fun attached(
path: File,
stateOf: (File) -> String? = { runCatching { Environment.getExternalStorageState(it) }.getOrNull() },
): Boolean = attached(path, stateOf(File(UsbLink.storagePath(path.path))))
internal fun attached(
path: File,
@@ -66,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
}
@@ -21,7 +21,7 @@ object PerfMode {
val cur = display.mode
val cap = minOf(peakRefreshCap(a), refreshLimit.takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY)
val best = selectMode(display.supportedModes, cur, fpsLimit, cap)
if (best != null && a.window.attributes.preferredDisplayModeId != best.modeId) {
if (best != null && needsChange(display.supportedModes, a.window.attributes.preferredDisplayModeId, best)) {
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
} else "display ${cur.refreshRate.toInt()} Hz"
@@ -64,6 +64,11 @@ object PerfMode {
?: sameSize.minByOrNull { it.refreshRate }
}
internal fun needsChange(modes: Array<Display.Mode>, preferredId: Int, best: Display.Mode): Boolean {
val preferred = modes.firstOrNull { preferredId != 0 && it.modeId == preferredId } ?: return true
return Math.abs(preferred.refreshRate - best.refreshRate) > 0.5f
}
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
if (fpsLimit <= 0) return true
val ratio = hz / fpsLimit
@@ -11,12 +11,4 @@ object PipPolicy {
height.toDouble() / width > 2.39 -> 100 to 239
else -> width to height
}
fun outputSize(width: Int, height: Int, outputWidth: Int, outputHeight: Int): Pair<Int, Int>? {
if (width <= 0 || height <= 0 || outputWidth <= 0 || outputHeight <= 0) return null
val scale = minOf(width.toFloat() / outputWidth, height.toFloat() / outputHeight)
val fittedWidth = ((outputWidth * scale).toInt() and 1.inv()).coerceAtLeast(2)
val fittedHeight = ((outputHeight * scale).toInt() and 1.inv()).coerceAtLeast(2)
return fittedWidth to fittedHeight
}
}
@@ -112,6 +112,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",
@@ -23,6 +23,7 @@ object SessionPrefs {
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
private const val LOW_STORAGE_WARNING = "lowStorageWarning"
private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff"
private const val SLOW_USB_DISMISSED = "slowUsbDismissed"
@@ -110,12 +111,16 @@ object SessionPrefs {
prefs(context).edit().remove("dualScreenLauncher").putString("dualScreenMode", launcher).putString("dualScreenSessionMode", mode).apply()
}
fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null)
fun setSecondScreenPanel(context: Context, mode: String) {
prefs(context).edit().putString("secondScreenPanel", mode).apply()
fun secondScreenPanel(context: Context, desktop: Boolean): String? = prefs(context).let {
it.getString(secondScreenPanelKey(desktop), null) ?: if (desktop) null else it.getString("secondScreenPanel", null)?.takeIf { id -> id != "custom-controls" }
}
fun setSecondScreenPanel(context: Context, desktop: Boolean, mode: String) {
prefs(context).edit().putString(secondScreenPanelKey(desktop), mode).apply()
}
private fun secondScreenPanelKey(desktop: Boolean) = if (desktop) "secondScreenPanel.desktop" else "secondScreenPanel.game"
/** The bottom-screen desktop takes touch as a trackpad unless the user switched it to direct touch. */
fun bottomDesktopDirectTouch(context: Context): Boolean = prefs(context).getBoolean("bottomDesktopDirectTouch", false)
@@ -184,6 +189,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply()
}
fun androidTextFieldKeyboard(context: Context): Boolean = prefs(context).getBoolean("androidTextFieldKeyboard", false)
fun setAndroidTextFieldKeyboard(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("androidTextFieldKeyboard", on).apply()
}
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
fun setGameStoresEnabled(context: Context, on: Boolean) {
@@ -351,6 +362,8 @@ object SessionPrefs {
}
const val PC_KEYBOARD_SIZE_DEFAULT = 47
const val WOW_CHAT_VOICE = "voice"
const val WOW_CHAT_KEYBOARD = "keyboard"
val PC_KEYBOARD_SIZE_RANGE = 25..70
const val PC_KEYBOARD_OPACITY_DEFAULT = 100
val PC_KEYBOARD_OPACITY_RANGE = 30..100
@@ -362,6 +375,13 @@ object SessionPrefs {
prefs(context).edit().putInt("pcKeyboardSize", percent.coerceIn(PC_KEYBOARD_SIZE_RANGE)).apply()
}
fun wowChatInput(context: Context): String? =
prefs(context).getString("wowChatInput", null)?.takeIf { it == WOW_CHAT_VOICE || it == WOW_CHAT_KEYBOARD }
fun setWowChatInput(context: Context, mode: String?) {
prefs(context).edit().apply { if (mode == null) remove("wowChatInput") else putString("wowChatInput", mode) }.apply()
}
fun pcKeyboardOpacity(context: Context): Int =
prefs(context).getInt("pcKeyboardOpacity", PC_KEYBOARD_OPACITY_DEFAULT).takeIf { it in PC_KEYBOARD_OPACITY_RANGE } ?: PC_KEYBOARD_OPACITY_DEFAULT
@@ -499,6 +519,17 @@ object SessionPrefs {
prefs(context).edit().putString("gpuDriverMode", mode).apply()
}
fun componentMode(context: Context, comp: String): String =
prefs(context).getString("componentMode.$comp", ComponentUpdates.AUTO)
?.takeIf { it == ComponentUpdates.AUTO || it == ComponentUpdates.MANUAL } ?: ComponentUpdates.AUTO
fun componentModeChosen(context: Context, comp: String): Boolean = prefs(context).contains("componentMode.$comp")
fun setComponentMode(context: Context, comp: String, mode: String) {
require(mode == ComponentUpdates.AUTO || mode == ComponentUpdates.MANUAL)
prefs(context).edit().putString("componentMode.$comp", mode).commit()
}
fun settleGpuDriverMode(context: Context) {
val p = prefs(context)
if (p.contains("gpuDriverMode")) return
@@ -1524,8 +1555,11 @@ 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))
fun setDeckPanel(context: Context, panel: com.droiddeck.launcher.input.DeckPanel) {
prefs(context).edit().putString("deckPanel", panel.id).apply()
}
fun suspendPolicy(context: Context, mode: String): String =
@@ -1556,6 +1590,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
}
fun lowStorageWarning(context: Context): Boolean = prefs(context).getBoolean(LOW_STORAGE_WARNING, true)
fun setLowStorageWarning(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(LOW_STORAGE_WARNING, on).apply()
}
fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false)
fun setSlowUsbWarningOff(context: Context) {
@@ -93,6 +93,7 @@ object SessionState {
@Volatile var secondScreenDisplay = -1
@Volatile var secondScreenAutoOpened = false
@Volatile var secondScreenProvisional = false
@Volatile var secondScreenAutomatic = false
@Volatile var gameOnBottom = false
@Volatile var steamLibraryMounts: Map<String, java.io.File> = emptyMap()
@@ -5,14 +5,15 @@ import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.InputProfiles
import com.droiddeck.launcher.runtime.LinuxRuntime
object SettingsReset {
private const val TAG = "SettingsReset"
internal val SESSION_KEYS = setOf(
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls",
"orientation", "launcherFullscreen", "animations", "lowStorageWarning", "theme", "runSteamAtStartup", "startupSession", "addedGamesArt",
"dualScreenMode", "dualScreenSessionMode", "dualScreenLauncher", "dualScreenSessionControls", "androidTextFieldKeyboard",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"storesInstallLanguage",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
@@ -30,7 +31,7 @@ object SettingsReset {
)
internal val SESSION_PREFIXES = listOf(
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.", "componentMode.",
)
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
@@ -43,6 +44,7 @@ object SettingsReset {
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit()
ControllerPrefs.resetAll(context)
InputProfiles.resetAll(context)
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("steam_touch", Context.MODE_PRIVATE).edit()
@@ -96,7 +96,7 @@ object SteamLibraries {
if (picked.none { overlaps(canonical(it.first), canonical(hit.first)) }) picked += hit
}
return picked.map { (dir, games) ->
val shown = File(storagePath(dir.path))
val shown = File(UsbLink.storagePath(dir.path))
Found(dir.path, shown.path, defaultLabel(context, shown), games)
}
}
@@ -155,7 +155,7 @@ object SteamLibraries {
}
fun defaultLabel(context: Context, picked: File): String {
val folder = File(storagePath(picked.path))
val folder = File(UsbLink.storagePath(picked.path))
val volume = try {
context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context)
} catch (e: Exception) {
@@ -171,9 +171,7 @@ object SteamLibraries {
internal fun overlaps(a: String, b: String): Boolean = a == b || a.startsWith("$b/") || b.startsWith("$a/")
private fun storagePath(path: String): String = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path
private fun canonical(file: File): String = storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
private fun canonical(file: File): String = UsbLink.storagePath(runCatching { file.canonicalPath }.getOrDefault(file.absolutePath))
private const val SCAN_DEPTH = 2
private const val SCAN_LIMIT = 4000
@@ -66,6 +66,9 @@ object StorageState {
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
fun shouldWarnLowSpace(warningEnabled: Boolean, dismissed: Boolean, sessionRunning: Boolean, freeBytes: Long): Boolean =
warningEnabled && !dismissed && !sessionRunning && freeBytes < LOW_SPACE_BYTES
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
@@ -65,6 +65,8 @@ private fun releaseDateValue(raw: String): Long =
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
} ?: 0L
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
fun <T> sortGames(
items: List<T>,
sort: String,
@@ -204,7 +204,8 @@ object BattleNetBackend : StoreBackend {
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
val art = BattleNet.art(context, game.title)
val cover = art.cover?.let { Uri.fromFile(it).toString() }
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
}
private fun shelves(context: Context): StoreShelves {
@@ -39,7 +39,7 @@ object GogComet {
var status by mutableStateOf("off")
private set
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", true)
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
@@ -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) }
@@ -49,7 +49,9 @@ import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import android.content.Context
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -60,6 +62,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AddedGameArt
@@ -192,7 +196,7 @@ internal fun SteamGameArtEditor(
game: com.droiddeck.launcher.frontend.Library.SteamGame, onClose: () -> Unit, onChanged: () -> Unit,
) {
val target = remember(game.appId) { ArtTarget.of(game) }
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged, launchAppId = game.appId)
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged)
}
@Composable
@@ -208,25 +212,17 @@ internal fun StoreGameArtEditor(folder: String, name: String, onClose: () -> Uni
@Composable
private fun ArtOnlyEditor(
name: String, target: ArtTarget?, refresh: Any?, describe: (Context) -> List<AddedGameArt.SlotState>,
onClose: () -> Unit, onChanged: () -> Unit, launchAppId: Int? = null,
onClose: () -> Unit, onChanged: () -> Unit,
) {
val app = LocalContext.current.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
var version by remember { mutableIntStateOf(0) }
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
var logoOpen by remember { mutableStateOf(false) }
var launchOptions by remember { mutableStateOf<String?>(null) }
var launchVersion by remember { mutableIntStateOf(0) }
var editingLaunch by remember { mutableStateOf<String?>(null) }
var launchSaving by remember { mutableStateOf(false) }
var launchError by remember { mutableStateOf<Int?>(null) }
val position = rememberLogoPosition(target, version)
LaunchedEffect(version, refresh) { if (target != null) arts = withContext(Dispatchers.IO) { describe(app) } }
LaunchedEffect(launchAppId, launchVersion) { if (launchAppId != null) launchOptions = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, launchAppId) } }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
AppDialog(shown, close, "artEditor", wide = false, maxWidth = 460.dp) {
@@ -236,19 +232,10 @@ private fun ArtOnlyEditor(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
if (launchAppId != null) {
FieldRow(stringResource(R.string.edit_launch_options), focus = first, onClick = { if (!launchSaving) launchOptions?.let { editingLaunch = it } }) {
val options = launchOptions
if (options == null || launchSaving) Loading() else ValueText(options.ifEmpty { "—" }, muted = options.isEmpty())
}
val error = launchError
if (error != null) Text(stringResource(error), fontSize = 12.sp, color = colors.error, modifier = Modifier.padding(start = 4.dp))
else Text(stringResource(R.string.edit_launch_options_steam_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp))
}
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = if (launchAppId != null) 8.dp else 2.dp))
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
for (slot in AddedGameArt.Slot.values()) key(slot) {
val state = arts.firstOrNull { it.slot == slot }
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER && launchAppId == null) first else null, onClick = { if (target != null) artSlot = slot }) {
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SlotThumb(slot, state?.file)
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
@@ -258,27 +245,6 @@ private fun ArtOnlyEditor(
PickerRow(Icons.Outlined.Tune, stringResource(R.string.art_adjust_logo), logoPositionSummary(position?.value), enabled = target != null && position != null) { logoOpen = true }
}
}
val before = editingLaunch
if (before != null && launchAppId != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editingLaunch = null }) { value ->
if (launchSaving || value == before.trim()) return@TextEntryDialog
launchSaving = true
scope.launch {
try {
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, launchAppId, value, before) }
launchError = when (saved) {
SteamLaunchOptions.Saved.YES -> null
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
}
if (saved == SteamLaunchOptions.Saved.YES) launchOptions = value else {
launchOptions = null
launchVersion++
}
} finally {
launchSaving = false
}
}
}
val chosen = target ?: return
artSlot?.let { slot ->
ArtChooserDialog(chosen, slot, arts.firstOrNull { it.slot == slot }, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
@@ -287,6 +253,72 @@ private fun ArtOnlyEditor(
if (logoOpen && loaded != null) LogoPositionHost(chosen, arts, loaded, onBack = { logoOpen = false }, onCloseAll = { logoOpen = false; close() }) { version++; onChanged() }
}
@Composable
internal fun SteamLaunchOptionsPanel(appId: Int) {
val app = LocalContext.current.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
var options by remember(appId) { mutableStateOf<String?>(null) }
var version by remember(appId) { mutableIntStateOf(0) }
var editing by remember(appId) { mutableStateOf<String?>(null) }
var saving by remember(appId) { mutableStateOf(false) }
var loading by remember(appId) { mutableStateOf(false) }
var error by remember(appId) { mutableStateOf<Int?>(null) }
LaunchedEffect(appId, version) {
options = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, appId) } ?: options.orEmpty()
loading = false
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle, appId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !saving) {
loading = true
version++
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
val value = options
MenuItem(
stringResource(R.string.edit_launch_options), checked = false, enabled = value != null && !saving && !loading,
detail = if (value == null || saving || loading) "…" else value.ifEmpty { "—" },
) { if (!saving && !loading) options?.let { editing = it } }
val message = error
Text(
stringResource(message ?: R.string.edit_launch_options_steam_hint),
fontSize = 12.sp,
color = if (message == null) colors.onSurfaceVariant else colors.error,
modifier = Modifier.padding(start = 4.dp),
)
}
val before = editing
if (before != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editing = null }) { value ->
if (saving || value == before.trim()) return@TextEntryDialog
saving = true
scope.launch {
try {
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, appId, value, before) }
error = when (saved) {
SteamLaunchOptions.Saved.YES -> null
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
}
if (saved == SteamLaunchOptions.Saved.YES) {
options = value
version++
} else {
options = null
version++
}
} finally {
saving = false
}
}
}
}
private class LoadedPosition(val value: LogoPosition.Value?)
@Composable
@@ -0,0 +1,226 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.WowAddonCatalog
import com.droiddeck.launcher.runtime.WowAddons
import com.droiddeck.launcher.session.SessionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private class AddonResults {
val items = mutableStateListOf<WowAddonCatalog.Addon>()
var page by mutableIntStateOf(0)
var loading by mutableStateOf(false)
var failed by mutableStateOf<String?>(null)
var more by mutableStateOf(false)
}
@Composable
internal fun AddonBrowserDialog(targets: List<WowAddons.Target>, initial: WowAddons.Target, onDismiss: () -> Unit) {
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var target by remember { mutableStateOf(initial) }
var text by remember { mutableStateOf("") }
var category by remember { mutableStateOf<WowAddonCatalog.Category?>(null) }
var sort by remember { mutableStateOf(WowAddonCatalog.Sort.POPULAR) }
val results = remember(text, category, sort, target) { AddonResults() }
val installing = remember { mutableStateMapOf<String, Int>() }
val installed = remember { mutableStateMapOf<String, Boolean>() }
val categories by produceState(emptyList<WowAddonCatalog.Category>()) {
value = withContext(Dispatchers.IO) { runCatching { WowAddonCatalog.categories(context) }.getOrDefault(emptyList()) }
}
fun load(page: Int) {
if (results.loading) return
results.loading = true
results.failed = null
val into = target
val words = text
val cat = category?.id
val order = sort
scope.launch {
val got = withContext(Dispatchers.IO) {
runCatching { WowAddonCatalog.search(context, WowAddonCatalog.Query(words, cat, WowAddonCatalog.client(into), order), page) }
}
got.onSuccess {
if (page == 0) results.items.clear()
results.items.addAll(it.items.filter { addon -> results.items.none { seen -> seen.id == addon.id } })
results.page = page
results.more = it.more
}.onFailure { results.failed = it.message ?: it.javaClass.simpleName }
results.loading = false
}
}
LaunchedEffect(results) {
if (text.isNotEmpty()) delay(350)
load(0)
}
fun install(addon: WowAddonCatalog.Addon) {
val key = installKey(addon, target)
if (key in installing) return
if (SessionState.running) {
Toast.makeText(context, R.string.main_addons_close_session, Toast.LENGTH_LONG).show()
return
}
val into = target
installing[key] = 0
scope.launch {
val message = withContext(Dispatchers.IO) { installAddon(context, addon, into) { pct -> scope.launch { if (key in installing) installing[key] = pct } } }
installing.remove(key)
if (message.second) installed[key] = true
Toast.makeText(context, message.first, Toast.LENGTH_LONG).show()
}
}
val sourceName = stringResource(R.string.addons_source_wowi)
AppDialog(shown, close, "addonBrowser", wide = true, scrollable = false) {
DialogTitleRow(stringResource(R.string.addons_download), close)
ModeHeader(sourceName, text, { text = it }, stringResource(R.string.stores_search_catalog, sourceName)) {
BrowseChip(stringResource(R.string.addons_source_curse), false) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(WowAddonCatalog.curseForgeUrl(text))).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
ChipRow {
BrowseDropdown(sortLabel(sort)) { first, done ->
WowAddonCatalog.Sort.entries.forEachIndexed { index, option ->
MenuItem(sortLabel(option), sort == option, focusRequester = if (index == 0) first else null) { sort = option; done() }
}
}
if (categories.isNotEmpty()) BrowseDropdown(category?.name ?: stringResource(R.string.addons_category_all)) { first, done ->
MenuItem(stringResource(R.string.addons_category_all), category == null, focusRequester = first) { category = null; done() }
categories.forEach { option -> MenuItem(option.name, category?.id == option.id) { category = option; done() } }
}
if (targets.size > 1) BrowseDropdown(target.flavor) { first, done ->
targets.forEachIndexed { index, option ->
MenuItem(option.flavor, option.addOns == target.addOns, focusRequester = if (index == 0) first else null) { target = option; done() }
}
}
}
val height = (LocalConfiguration.current.screenHeightDp * 0.62f).dp
LazyVerticalGrid(
GridCells.Adaptive(libraryTileWidth() + 40.dp), Modifier.fillMaxWidth().height(height),
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(results.items, key = { it.id }) { addon ->
AddonCard(addon, installing[installKey(addon, target)], installed[installKey(addon, target)] == true) { install(addon) }
}
item(key = "tail", span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
when {
results.loading -> LinearProgressIndicator(Modifier.fillMaxWidth())
results.failed != null -> BrowseChip(stringResource(R.string.stores_dl_retry), false) { load(if (results.items.isEmpty()) 0 else results.page + 1) }
results.more -> BrowseChip(stringResource(R.string.stores_itch_load_more), false) { load(results.page + 1) }
results.items.isEmpty() -> Text(stringResource(R.string.addons_none_found), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@Composable
private fun AddonCard(addon: WowAddonCatalog.Addon, progress: Int?, done: Boolean, onInstall: () -> Unit) {
val pal = LocalPalette.current
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
val hot = rememberHot(src)
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
Column(
Modifier.fillMaxWidth().clip(shape).glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onInstall)
.controllerConfirm(onClick = onInstall),
) {
ArtCard(addon.name, addon.thumb, null, null, hot, capsule = false, chip = progress?.let { "$it%" }) {
if (done) Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.height(11.dp))
else Text(stringResource(if (progress != null) R.string.store_installing else R.string.store_install), fontSize = 10.sp, color = pal.signal, maxLines = 1)
}
Text(
listOf(addon.author, formatCount(addon.downloads)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
}
@Composable
private fun sortLabel(sort: WowAddonCatalog.Sort): String = stringResource(
when (sort) {
WowAddonCatalog.Sort.POPULAR -> R.string.stores_itch_sort_popular
WowAddonCatalog.Sort.UPDATED -> R.string.stores_itch_sort_new
WowAddonCatalog.Sort.NAME -> R.string.addons_sort_name
WowAddonCatalog.Sort.DOWNLOADS -> R.string.addons_sort_downloads
},
)
private fun formatCount(count: Long): String = when {
count <= 0 -> ""
count >= 1_000_000 -> String.format(java.util.Locale.getDefault(), "%.1fM", count / 1_000_000.0)
count >= 1_000 -> String.format(java.util.Locale.getDefault(), "%.1fK", count / 1_000.0)
else -> count.toString()
}
private fun installAddon(context: Context, addon: WowAddonCatalog.Addon, target: WowAddons.Target, progress: (Int) -> Unit): Pair<String, Boolean> {
val zip = File(File(context.cacheDir, "wowaddons"), "download-wowi-${addon.id.filter(Char::isLetterOrDigit)}-${java.util.UUID.randomUUID()}.zip")
return try {
WowAddonCatalog.download(context, addon, zip, progress)
if (SessionState.running) return context.getString(R.string.main_addons_close_session) to false
val names = WowAddons.import(zip, target)
if (names.isEmpty()) context.getString(R.string.main_addons_none, addon.name) to false
else context.resources.getQuantityString(R.plurals.main_addons_imported, names.size, names.size, target.flavor) to true
} catch (_: WowAddons.TooLarge) {
context.getString(R.string.main_addons_too_large, addon.name) to false
} catch (e: Exception) {
context.getString(R.string.stores_dl_failed_reason, e.message ?: e.javaClass.simpleName) to false
} finally {
zip.delete()
}
}
private fun installKey(addon: WowAddonCatalog.Addon, target: WowAddons.Target) = addon.id + "|" + target.addOns.path
@@ -12,6 +12,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.input.DeckPanel
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
@@ -59,6 +61,8 @@ internal class BarExtra(
val onClick: () -> Unit,
)
internal class BarTabs(val labels: List<String>, val selected: Int, val onSelect: (Int) -> Unit)
internal class BottomBarModel(
val title: String?,
val onClose: (() -> Unit)?,
@@ -67,10 +71,22 @@ internal class BottomBarModel(
val onQam: (() -> Unit)?,
val extras: List<BarExtra> = emptyList(),
val menuActive: Boolean = false,
val tabs: BarTabs? = null,
)
@Composable
internal fun BottomScreenBar(model: BottomBarModel, modifier: Modifier = Modifier) {
val tabs = model.tabs
if (tabs == null) BarRow(model, modifier) else Column(modifier.fillMaxWidth().blockTouches()) {
BarRow(model)
Box(Modifier.fillMaxWidth().padding(start = BAR_PAD, end = BAR_PAD, bottom = 8.dp), contentAlignment = Alignment.Center) {
TabStrip(tabs.labels, tabs.selected, tabs.onSelect, Modifier.testTag("bottom-bar-tabs"), bumpers = false)
}
}
}
@Composable
private fun BarRow(model: BottomBarModel, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
BoxWithConstraints(modifier.fillMaxWidth().height(BAR_HEIGHT).blockTouches()) {
val buttons = listOfNotNull(model.onClose, model.onMenu, model.onQam).size + model.extras.size
@@ -195,11 +211,15 @@ internal fun sessionBarModel(
onSteam: () -> Unit,
onQam: () -> Unit,
extras: List<BarExtra> = emptyList(),
tabs: BarTabs? = null,
): BottomBarModel {
val steam = SessionState.mode == SessionService.MODE_STEAM
return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras)
return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras, tabs = tabs)
}
internal fun deckPanelTabs(context: Context, selected: DeckPanel, onSelect: (DeckPanel) -> Unit) =
BarTabs(DeckPanel.entries.map { context.getString(it.label) }, selected.ordinal) { onSelect(DeckPanel.entries[it]) }
internal fun bottomScreenBarView(context: Context, model: () -> BottomBarModel): ComposeView = ComposeView(context).apply {
setContent { DroidDeckTheme { BottomScreenBar(model()) } }
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -110,7 +111,16 @@ internal class BottomScreenTextInput(private val fallback: TextInputService) : P
}
@Composable
internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
internal fun TypingPanel(
request: TypingRequest,
modifier: Modifier = Modifier.fillMaxSize(),
title: String = stringResource(R.string.dual_typing_title),
done: String = stringResource(R.string.dual_typing_done),
onCancel: (() -> Unit)? = null,
leading: (@Composable () -> Unit)? = null,
showKeyboard: Boolean = true,
onDone: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
var field by remember(request) { mutableStateOf(request.value) }
LaunchedEffect(request.value) {
@@ -120,19 +130,22 @@ internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(request) {
kotlinx.coroutines.delay(120)
if (!showKeyboard) return@LaunchedEffect
runCatching { focus.requestFocus() }
keyboard?.show()
}
val options = request.imeOptions
val secret = options.keyboardType == KeyboardType.Password || options.keyboardType == KeyboardType.NumberPassword
Column(
Modifier.fillMaxSize().background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
modifier.background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.dual_typing_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
leading?.invoke()
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
color = colors.onSurfaceVariant, modifier = Modifier.weight(1f))
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(stringResource(R.string.dual_typing_done)) }
if (onCancel != null) OutlinedButton(onClick = onCancel, modifier = Modifier.testTag("dual-typing-cancel")) { Text(stringResource(R.string.common_cancel)) }
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(done) }
}
OutlinedTextField(
value = field,
@@ -81,6 +81,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
@@ -163,6 +165,11 @@ fun ComponentsPage(
protons: ProtonsState = ProtonsState(),
protonActions: ProtonsActions = ProtonsActions(),
protonOptions: @Composable () -> Unit = {},
compModes: Map<String, String> = emptyMap(),
compAuto: Map<String, List<ComponentUpdater.Status>> = emptyMap(),
compAutoHeld: Set<String> = emptySet(),
compAutoChecking: Boolean = false,
onCompMode: (String, String) -> Unit = { _, _ -> },
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -521,6 +528,9 @@ fun ComponentsPage(
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
}
}
val followed = view?.proton?.let(ComponentUpdates::follows) == true
val moded: (String) -> Boolean = { followed && it in compModes && it !in compAutoHeld && !ComponentsManager.runtimeManaged(it, gpu.systemPowerVr) }
val automatic: (String) -> Boolean = { moded(it) && compModes[it] == ComponentUpdates.AUTO }
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
when (l) {
PROTON_TAB -> {
@@ -535,7 +545,7 @@ fun ComponentsPage(
(if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
v.proton.id == view?.proton?.id, removable = removable,
modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
modifier = (if (i == 0 && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } },
onDelete = {
if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), ctx.getString(R.string.proton_remove_body), ctx.getString(R.string.store_remove), true, v.proton.name) {
@@ -555,7 +565,7 @@ fun ComponentsPage(
val key = "row:$l:${item.file ?: item.protonVersion}"
ChoiceLine(
item.name, item.detail, item.selected, item.removable, tag = item.tag, enabled = !managedDxvk,
modifier = (if (i == 0 && !managedDxvk) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
modifier = (if (i == 0 && !managedDxvk && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
onSelect = {
if (managedDxvk || item.selected || p == null) return@ChoiceLine
val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long)
@@ -680,15 +690,31 @@ fun ComponentsPage(
label = "layerRows",
) { k -> body(k) }
}
if (single) {
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
Spacer(Modifier.height(12.dp))
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
if (moded(l)) slide { k ->
if (moded(k)) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(bottom = 14.dp)) {
PanelHeading(stringResource(R.string.comp_mode_title))
ModeChoices(
listOf(
Triple(ComponentUpdates.AUTO, R.string.common_auto, R.string.comp_mode_auto_detail),
Triple(ComponentUpdates.MANUAL, R.string.drivers_manual, R.string.comp_mode_manual_detail),
),
if (automatic(k)) ComponentUpdates.AUTO else ComponentUpdates.MANUAL, single, firstRow,
onPick = { onCompMode(k, it) },
)
if (automatic(k)) AutoComponentBody(ComponentsManager.LABEL[k] ?: k, compAuto[k].orEmpty(), compAutoChecking)
}
}
if (!automatic(l)) {
if (single) {
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
Spacer(Modifier.height(12.dp))
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
}
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
}
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
if (l == "fex") {
SettingsGroup(stringResource(R.string.fex_preset_title)) {
SettingsRow(stringResource(R.string.fex_preset_title), null) {
@@ -1078,6 +1104,25 @@ private fun RowGroup(title: String, content: @Composable ColumnScope.() -> Unit)
}
}
@Composable
private fun AutoComponentBody(label: String, lines: List<ComponentUpdater.Status>, checking: Boolean) {
val colors = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
PanelTitle(stringResource(R.string.comp_auto_title, label), stringResource(R.string.comp_auto_hint))
if (lines.isEmpty()) EmptyLine(stringResource(R.string.comp_auto_none))
for (line in lines) {
val previous = line.previous.removeSuffix(".wcp").ifEmpty { stringResource(R.string.comp_tag_original) }
val fallback = if (line.current.isEmpty() || line.current == line.previous) null else stringResource(R.string.comp_auto_fallback, previous)
PanelRow(
line.current.removeSuffix(".wcp").ifEmpty { stringResource(if (checking) R.string.comp_auto_checking else R.string.comp_tag_original) },
listOfNotNull(line.protons.joinToString(", ").ifEmpty { null }, fallback).joinToString(" · "),
selected = true, onClick = null,
)
}
lines.map { it.note }.filter { it.isNotEmpty() }.distinct().forEach { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
}
}
@Composable
private fun EmptyLine(text: String) {
Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp))
@@ -1138,6 +1183,7 @@ private fun GameComponentOverrides(
open = fexMenu,
enabled = config != null && !busy,
allowInheritance = true,
generalPreset = generalFexPreset,
onToggle = { onFexMenu(!fexMenu) },
onDismiss = { onFexMenu(false) },
onPick = onFexPreset,
@@ -1304,15 +1350,16 @@ internal fun FexPresetChip(
onDismiss: () -> Unit,
onPick: (String?) -> Unit,
allowInheritance: Boolean = false,
generalPreset: String = preset,
modifier: Modifier = Modifier,
) {
val chosen = FexPreset.all.firstOrNull { it.id == preset }
val chosen = FexPreset.all.firstOrNull { it.id == if (inherited) generalPreset else preset }
val label = when {
preset == GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom)
chosen == null -> stringResource(FexPreset.byId("").label)
inherited -> stringResource(R.string.comp_game_use_general)
else -> stringResource(chosen.label)
}
val general = FexPreset.all.firstOrNull { it.id == generalPreset }
Box {
ValueChip(label, open, enabled = enabled, modifier = modifier.widthIn(max = 260.dp), onClick = onToggle)
AnchoredMenu(open && enabled, onDismiss = onDismiss, title = stringResource(R.string.fex_preset_title)) { first ->
@@ -1320,6 +1367,7 @@ internal fun FexPresetChip(
MenuItem(
stringResource(R.string.comp_game_use_general),
checked = inherited,
detail = general?.let { stringResource(it.label) },
focusRequester = first,
) { onPick(null); onDismiss() }
}
@@ -1,111 +1,20 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.session.SessionPrefs
class ControllerActions(
val onOsc: (String) -> Unit,
val onTint: (Int) -> Unit,
val onOpacity: (Int) -> Unit,
val onSize: (Int) -> Unit,
val onStickClick: (Boolean) -> Unit,
val onAdaptiveSticks: (Boolean) -> Unit,
val onRumble: (Boolean) -> Unit,
val onDeadZone: (Int) -> Unit,
val onSteamButton: (Boolean) -> Unit,
val onQamButton: (Boolean) -> Unit,
val onKeyboardButton: (Boolean) -> Unit,
val onSelectSteam: (Boolean) -> Unit,
val onJoyConVr: (Boolean) -> Unit,
val onEditLayout: () -> Unit,
val onResetLayout: () -> Unit,
val onMapping: () -> Unit,
val onResetAll: () -> Unit,
)
class ControllerActions(val onMapping: () -> Unit)
@Composable
private fun Swatch(color: Int) {
Box(Modifier.size(14.dp).background(Color(color), CircleShape).border(1.dp, Color.White.copy(alpha = 0.35f), CircleShape))
}
@Composable
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
ChoiceRow(
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second?.let { stringResource(it) } ?: stringResource(R.string.ctrl_custom), tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
}
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
MenuItem(stringResource(name), checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTint(color)
host.open = null
}
}
}
}
}
ChoiceRow(host, "controller-opacity", stringResource(R.string.ctrl_opacity), null, ControllerPrefs.opacities.map { it to stringResource(R.string.ctrl_percent, it) }, c.opacity, onPick = a.onOpacity)
ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), stringResource(R.string.ctrl_size_hint), ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize)
ToggleRow(host, "controller-stick-click", stringResource(R.string.ctrl_stick_click), stringResource(R.string.ctrl_stick_click_hint), c.stickClick, onChange = a.onStickClick)
ToggleRow(host, "controller-adaptive", stringResource(R.string.ctrl_adaptive), stringResource(R.string.ctrl_adaptive_hint), c.adaptiveSticks, onChange = a.onAdaptiveSticks)
ToggleRow(host, "controller-rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, onChange = a.onRumble)
ChoiceRow(host, "controller-dead-zone", stringResource(R.string.ctrl_dead_zone), null, ControllerPrefs.deadZones.map { it to stringResource(R.string.ctrl_percent, it) }, c.deadZone, onPick = a.onDeadZone)
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), c.joyConVr, onChange = a.onJoyConVr)
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() }
}
}
val remapped = c.mapping.count { (id, target) -> id != target }
ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping)
ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll)
}
@Composable
fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) {
val host = rememberMenuHost()
SettingsPage(
host, title = stringResource(R.string.ctrl_mapping), eyebrow = stringResource(R.string.setup_controller),
lede = stringResource(R.string.ctrl_mapping_lede),
onBack = onBack,
) {
SettingsGroup(stringResource(R.string.shortcuts_title)) {
PhysicalShortcutRows()
}
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
for ((id, name) in ControllerPrefs.mappable) {
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
}
}
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
ActionRow(stringResource(R.string.ctrl_reset_mapping), stringResource(R.string.ctrl_mapping_default), stringResource(R.string.ctrl_reset), onReset)
}
internal fun rememberControllerSettings(): ControllerPrefs.Settings {
val context = LocalContext.current
var settings by remember { mutableStateOf(ControllerPrefs.read(context)) }
DisposableEffect(context) {
val prefs = ControllerPrefs.prefs(context)
val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> settings = ControllerPrefs.read(context) }
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
}
return settings
}
@@ -1,720 +0,0 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.RectF
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionState
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.min
@SuppressLint("ViewConstructor")
class DeckControlsPanel(
context: Context,
private val onClose: () -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
private val pad: PadBridge? = null,
private val portraitDock: Boolean = false,
private val onMenu: (() -> Unit)? = null,
private val onSwap: (() -> Unit)? = null,
) : LinearLayout(context) {
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
private val content = FrameLayout(context)
val deckControls = pad == null || SessionState.deckPad
private val tabViews = HashMap<Tab, TextView>()
private val topBar: View by lazy { tabsRow() }
init {
orientation = VERTICAL
setBackgroundColor(Color.BLACK)
val title = context.getString(if (deckControls) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad)
val extras = listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, onClick = it) })
addView(bottomScreenBarView(context) { sessionBarModel(title, onClose, onMenu, onSteamMenu, onQam, extras) },
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(52)))
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
select(if (pad != null && !deckControls) Tab.GAMEPAD else if (pad != null) selectedWithPad else selected)
}
override fun onDetachedFromWindow() {
DeckControls.releaseAll()
super.onDetachedFromWindow()
}
private fun tabsRow(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER
setPadding(dp(12), 0, dp(12), dp(8))
val tabs = LinearLayout(context).apply {
orientation = HORIZONTAL
background = outline(CORNER_PILL, SURFACE)
setPadding(dp(4), dp(4), dp(4), dp(4))
for (tab in Tab.values()) {
if (!deckControls && tab != Tab.GAMEPAD) continue
if (tab == Tab.GAMEPAD && pad == null) continue
val view = TextView(context).apply {
text = context.getString(tab.label)
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
isSingleLine = true
setPadding(dp(14), 0, dp(14), 0)
setOnClickListener { select(tab) }
}
tabViews[tab] = view
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
}
}
val centre = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44)))
}
addView(centre, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
}
private fun select(tab: Tab) {
if (pad != null) selectedWithPad = tab else selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
tabViews[tab]?.let { selectedView ->
(selectedView.parent?.parent as? HorizontalScrollView)?.post {
val left = selectedView.left
val right = selectedView.right
val scroll = selectedView.parent?.parent as? HorizontalScrollView ?: return@post
when {
left < scroll.scrollX -> scroll.smoothScrollTo(left, 0)
right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0)
}
}
}
content.removeAllViews()
topBar.visibility = if (tab == Tab.GAMEPAD || tabViews.size < 2) View.GONE else View.VISIBLE
content.addView(
when (tab) {
Tab.GRIPS -> gripsGrid()
Tab.LEFT -> singlePad(right = false)
Tab.RIGHT -> singlePad(right = true)
Tab.BOTH -> bothPads()
Tab.GAMEPAD -> gamepad(pad ?: return)
},
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
)
}
private fun gripsGrid(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
setPadding(dp(12), dp(6), dp(12), dp(12))
fun column(upper: View, lower: View) = LinearLayout(context).apply {
orientation = VERTICAL
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
}
addView(column(GripButton(context, "L4", context.getString(R.string.deck_left_upper), DeckControls.L4), GripButton(context, "L5", context.getString(R.string.deck_left_lower), DeckControls.L5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(column(GripButton(context, "R4", context.getString(R.string.deck_right_upper), DeckControls.R4), GripButton(context, "R5", context.getString(R.string.deck_right_lower), DeckControls.R5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
}
private fun bothPads(): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
val pads = LinearLayout(context).apply {
orientation = HORIZONTAL
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
val grips = LinearLayout(context).apply {
orientation = HORIZONTAL
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
row.forEachIndexed { index, (label, bit) ->
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
if (index > 0) leftMargin = dp(6)
if (index < row.size - 1) rightMargin = dp(6)
})
}
}
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
}
private fun gamepad(pad: PadBridge): View = GamepadLayout(context, portraitDock).apply {
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
if (deckControls) place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
GamepadGeometry.Slot.PANELS)
if (portraitDock && deckControls) {
place(GripButton(context, "L4", null, DeckControls.L4), GamepadGeometry.Slot.L4)
place(GripButton(context, "L5", null, DeckControls.L5), GamepadGeometry.Slot.L5)
place(GripButton(context, "R5", null, DeckControls.R5), GamepadGeometry.Slot.R5)
place(GripButton(context, "R4", null, DeckControls.R4), GamepadGeometry.Slot.R4)
}
}
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
text = label
contentDescription = description
textSize = 13f
maxLines = 1
setAutoSizeTextTypeUniformWithConfiguration(9, 13, 1, android.util.TypedValue.COMPLEX_UNIT_SP)
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = 0.08f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(CORNER_PILL, SURFACE)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
if (!deckControls) {
addView(StickView(context, pad, right),
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
return@apply
}
val slot = this
fun show() {
slot.removeAllViews()
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
val chip = TextView(context).apply {
text = "\u21c4"
contentDescription = context.getString(R.string.deck_surface_switch)
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
setTextColor(TEXT_DIM)
background = outline(CORNER_PILL, SURFACE)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
SessionPrefs.setGamepadSurface(context, right,
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
show()
}
}
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
}
show()
}
fun takesTouchAt(x: Float, y: Float): Boolean = controlUnder(this, x, y)
private fun controlUnder(view: View, x: Float, y: Float): Boolean {
if (view.visibility != VISIBLE || x < 0f || y < 0f || x >= view.width || y >= view.height) return false
if (view.isClickable || view is HorizontalScrollView || view is Surface || view is DirectionPad || view is FaceButtons ||
view is androidx.compose.ui.platform.ComposeView) return true
val group = view as? ViewGroup ?: return false
return (group.childCount - 1 downTo 0).any { i ->
val child = group.getChildAt(i)
controlUnder(child, x + group.scrollX - child.x, y + group.scrollY - child.y)
}
}
private fun outline(radius: Float, fill: Int) = outline(context, radius, fill)
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
}
private fun dp(value: Int): Int = dp(context, value)
private abstract class Surface(context: Context) : View(context) {
protected val density = resources.displayMetrics.density
protected val rect = RectF()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
color = TEXT_DIM
}
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
val inset = density
rect.set(inset, inset, width - inset, height - inset)
val radius = CORNER * density
fill.color = if (lit) ACCENT_WASH else SURFACE
canvas.drawRoundRect(rect, radius, radius, fill)
stroke.color = if (lit) ACCENT else LINE
stroke.strokeWidth = (if (lit) 2f else 1f) * density
canvas.drawRoundRect(rect, radius, radius, stroke)
}
}
private open class HoldButton(
context: Context,
private val label: String,
private val caption: String?,
private val onChange: (Boolean) -> Unit,
) : Surface(context) {
private var down = false
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, down)
val cx = width / 2f
labelPaint.textSize = min(height * 0.3f, 30 * density)
labelPaint.color = if (down) ACCENT else TEXT
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
canvas.drawText(label, cx, labelY, labelPaint)
if (caption != null) {
captionPaint.textSize = 12 * density
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> set(true)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
}
return true
}
private fun set(pressed: Boolean) {
if (down == pressed) return
down = pressed
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onChange(pressed)
invalidate()
}
}
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
private class ClickBar(context: Context, right: Boolean) :
HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { DeckControls.setClick(right, it) })
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
private var fingerY = 0f
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicked)
val cx = width / 2f
val cy = height / 2f
guide.strokeWidth = density
val reach = min(width, height) / 2f - 24 * density
canvas.drawCircle(cx, cy, reach, guide)
canvas.drawCircle(cx, cy, reach / 2f, guide)
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
if (touching) {
val radius = 56 * density
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
canvas.drawCircle(fingerX, fingerY, radius, glow)
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
touch(event.getX(0), event.getY(0))
click(event.pointerCount > 1)
}
MotionEvent.ACTION_POINTER_DOWN -> click(true)
MotionEvent.ACTION_POINTER_UP -> {
val stays = if (event.actionIndex == 0) 1 else 0
touch(event.getX(stays), event.getY(stays))
click(event.pointerCount - 1 > 1)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
click(false)
touching = false
DeckControls.setPad(right, touching = false)
invalidate()
}
}
return true
}
private fun touch(x: Float, y: Float) {
fingerX = x.coerceIn(0f, width.toFloat())
fingerY = y.coerceIn(0f, height.toFloat())
touching = true
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
invalidate()
}
private fun click(down: Boolean) {
if (clicked == down) return
clicked = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
DeckControls.setClick(right, down)
invalidate()
}
}
private class PadButton(
context: Context,
private val pad: PadBridge,
label: String,
private val write: (PadState, Boolean) -> Unit,
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
override fun onDetachedFromWindow() {
pad.applyTouch { write(it, false) }
super.onDetachedFromWindow()
}
}
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicking = false
private var lastUp = 0L
private var originX = 0f
private var originY = 0f
private var knobX = 0f
private var knobY = 0f
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
private fun reach() = min(width, height) * 0.30f
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicking)
val cx = if (touching) originX else width / 2f
val cy = if (touching) originY else height / 2f
ring.strokeWidth = 2 * density
ring.color = if (touching) ACCENT else LINE
canvas.drawCircle(cx, cy, reach(), ring)
knob.color = if (touching) ACCENT else KNOB
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val r = reach().coerceAtLeast(1f)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
touching = true
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
follow(event.x, event.y, r)
}
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
touching = false
lastUp = event.eventTime
setClick(false)
move(0f, 0f, 1f)
}
}
return true
}
private fun follow(x: Float, y: Float, r: Float) {
var dx = x - originX
var dy = y - originY
val length = hypot(dx, dy)
if (length > r) { dx *= r / length; dy *= r / length }
move(dx, dy, r)
}
private fun move(dx: Float, dy: Float, r: Float) {
knobX = dx
knobY = dy
pad.applyTouch(if (right) PadBridge.RIGHT_STICK else PadBridge.LEFT_STICK) { s ->
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
}
invalidate()
}
private fun setClick(down: Boolean) {
if (clicking == down) return
clicking = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
}
override fun onDetachedFromWindow() {
setClick(false)
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
super.onDetachedFromWindow()
}
}
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
private val density = resources.displayMetrics.density
private var held = 0
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
val size = min(width, height) * 0.86f
val cx = width / 2f
val cy = height / 2f
val half = size / 2f
val thick = size / 3f / 2f
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
arm.color = if (held and bit != 0) ACCENT else KNOB
rect.set(l, t, r, b)
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
}
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
arm.color = KNOB
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val bits = when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
val dx = event.getX(0) - width / 2f
val dy = event.getY(0) - height / 2f
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
}
}
else -> 0
}
set(bits)
return true
}
private fun set(bits: Int) {
if (bits == held) return
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
held = bits
pad.applyTouch { s ->
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
}
invalidate()
}
override fun onDetachedFromWindow() {
set(0)
super.onDetachedFromWindow()
}
}
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
private var held = emptySet<Int>()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
val cx = width / 2f
val cy = height / 2f
val spread = min(width, height) * 0.30f
val radius = min(width, height) * 0.16f
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
}
override fun onDraw(canvas: Canvas) {
val (centres, radius) = layout()
text.textSize = radius * 0.9f
buttons.forEachIndexed { i, (button, label) ->
val (x, y) = centres[i]
val down = button in held
fill.color = if (down) ACCENT else KNOB
canvas.drawCircle(x, y, radius, fill)
text.color = if (down) ON_ACCENT else TEXT
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val (centres, radius) = layout()
val now = HashSet<Int>()
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
if (!ending) for (p in 0 until event.pointerCount) {
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
val x = event.getX(p)
val y = event.getY(p)
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
}
set(now)
return true
}
private fun set(now: Set<Int>) {
if (now == held) return
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
val before = held
held = now
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
invalidate()
}
override fun onDetachedFromWindow() {
set(emptySet())
super.onDetachedFromWindow()
}
}
private class GamepadLayout(context: Context, private val portraitDock: Boolean) : ViewGroup(context) {
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
fun place(view: View, slot: GamepadGeometry.Slot) {
slots[view] = slot
addView(view)
}
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density, portraitDock)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(w, h)
val boxes = boxes(w, h)
for ((view, slot) in slots) {
val box = boxes[slot]
view.visibility = if (box == null) View.GONE else View.VISIBLE
if (box == null) continue
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val boxes = boxes(r - l, b - t)
for ((view, slot) in slots) {
val box = boxes[slot] ?: continue
val left = box.left.toInt()
val top = box.top.toInt()
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
}
}
}
companion object {
private const val CLICK_MS = 300L
private var selected = Tab.BOTH
private var selectedWithPad = Tab.BOTH
private const val CORNER = 20f
private const val CORNER_PILL = 22f
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
internal fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density + 0.5f).toInt()
internal fun outline(context: Context, radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * context.resources.displayMetrics.density
setColor(fill)
setStroke(dp(context, 1), LINE)
}
internal fun roundButton(context: Context, label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
TextView(context).apply {
text = label
contentDescription = description
textSize = if (wide) 13f else 20f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = if (wide) 0.08f else 0f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(context, CORNER_PILL, SURFACE)
setPadding(if (wide) dp(context, 16) else 0, 0, if (wide) dp(context, 16) else 0, 0)
minWidth = dp(context, 44)
layoutParams = LinearLayout.LayoutParams(if (wide) LinearLayout.LayoutParams.WRAP_CONTENT else dp(context, 44), dp(context, 44))
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
internal fun menuButton(context: Context, onClick: () -> Unit) =
iconButton(context, R.drawable.ic_stat_session, context.getString(R.string.touch_droiddeck_menu), onClick)
internal fun iconButton(context: Context, icon: Int, description: String, onClick: () -> Unit) = android.widget.ImageView(context).apply {
setImageResource(icon)
setColorFilter(TEXT)
contentDescription = description
scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
val pad = dp(context, 11)
setPadding(pad, pad, pad, pad)
background = outline(context, CORNER_PILL, SURFACE)
layoutParams = LinearLayout.LayoutParams(dp(context, 44), dp(context, 44))
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
}
}
@@ -115,6 +115,9 @@ class FrontEndState(
val adbPaired: Boolean = false,
val backgroundAppLimit: Int = -1,
val memoryHud: String = "off",
val tpUbwcMode: String? = null,
val tpUbwcRestartRequired: Boolean = false,
val canRestartApp: Boolean = false,
val steamGames: List<Library.SteamGame>,
val steamGamesX64: Boolean = false,
val emulators: List<Library.Emulator>,
@@ -122,6 +125,7 @@ class FrontEndState(
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
val lossless: Lossless.State = Lossless.State.NONE,
val shortcutPicker: Boolean = false,
val preparingGames: Set<String> = emptySet(),
val shortcutLibraryScanning: Boolean = false,
val gameSyncFolder: String? = null,
val pageKey: String? = null,
@@ -140,6 +144,7 @@ class FrontEndState(
val dualScreenMode: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO,
val dualScreenSessions: String = com.droiddeck.launcher.input.DualScreen.MODE_AUTO,
val dualScreenControls: Boolean = true,
val androidTextFieldKeyboard: Boolean = false,
val packages: List<PackageRow>? = null,
val packageCatalogLoading: Boolean = false,
val packageBusyId: String? = null,
@@ -169,6 +174,7 @@ class FrontEndState(
val downloadPrefix: String = "",
val downloadProxy: String = "",
val animationsEnabled: Boolean = true,
val lowStorageWarning: Boolean = true,
val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK,
val gameStoresEnabled: Boolean = true,
val gameStoresSpeedTier: String = "fast",
@@ -188,6 +194,7 @@ class FrontEndActions(
val onPlayDesktopUi: () -> Unit,
val onSteamUrl: (String) -> Unit = {},
val onSteamGame: (Library.SteamGame) -> Unit,
val onStoreGame: (com.droiddeck.launcher.stores.Store, String) -> Unit = { _, _ -> },
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onExportGameFile: (Library.SteamGame) -> Unit = {},
val onSyncGameFiles: () -> Unit = {},
@@ -243,6 +250,8 @@ class FrontEndActions(
val onSteamGpuDrawing: () -> Unit = {},
val onClearCachedApps: (Boolean) -> Unit = {},
val onMemoryHud: (String) -> Unit = {},
val onTpUbwcMode: (String) -> Unit = {},
val onRestartApp: () -> Unit = {},
val onBackgroundAppLimit: (Int) -> Unit = {},
val onShareLogs: () -> Unit = {},
val onShareSessionLog: (File) -> Unit = {},
@@ -260,9 +269,11 @@ class FrontEndActions(
val onLauncherOrientation: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onLowStorageWarning: (Boolean) -> Unit = {},
val onDualScreenMode: (String) -> Unit = {},
val onDualScreenSessions: (String) -> Unit = {},
val onDualScreenControls: (Boolean) -> Unit = {},
val onAndroidTextFieldKeyboard: (Boolean) -> Unit = {},
val onDownloadSource: (String) -> Unit = {},
val onDownloadMirror: (String) -> Unit = {},
val onDownloadPrefix: (String) -> Unit = {},
@@ -245,7 +245,7 @@ internal fun SetupPanel(
PageHeader(stringResource(R.string.setup_title)) {
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, onPick = pick) }
}
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
Column(modifier = Modifier.weight(1f).fillMaxWidth().then(if (tab == 1) Modifier else Modifier.verticalScroll(rememberScrollState())).padding(bottom = if (tab == 1) 0.dp else 24.dp)) {
when (tab) {
0 -> {
// Setup's "Safe mode" row, under the GPU row while the bridge is on and can be used here.
@@ -413,9 +413,7 @@ internal fun SetupPanel(
}
1 -> {
val controller = s.controller
if (controller != null && a.controller != null) SettingsGroup(stringResource(R.string.setup_controller)) {
ControllerRows(host, s.oscMode, controller, a.controller)
}
if (controller != null && a.controller != null) InputSettingsPage(onBack = { pick(0) }, embedded = true)
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
}
2 -> {
@@ -452,6 +450,8 @@ internal fun SetupPanel(
)
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
ToggleRow(host, "low-storage-warning", stringResource(R.string.setup_low_storage_warning), stringResource(R.string.setup_low_storage_warning_hint),
s.lowStorageWarning, onChange = a.onLowStorageWarning)
val railContext = androidx.compose.ui.platform.LocalContext.current
remember { RailLabels.load(railContext) }
ToggleRow(host, "rail-labels", stringResource(R.string.setup_rail_labels), stringResource(R.string.setup_rail_labels_hint), RailLabels.shown) { RailLabels.set(railContext, it) }
@@ -474,6 +474,20 @@ internal fun SetupPanel(
com.droiddeck.launcher.core.AppUiPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.launcherOrientation, onPick = a.onLauncherOrientation,
)
s.tpUbwcMode?.let { ubwc ->
ChoiceRow(
host, "tp-ubwc", stringResource(R.string.gpu_ubwc_title), stringResource(R.string.gpu_ubwc_hint),
listOf(
SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
), ubwc, onPick = a.onTpUbwcMode,
)
if (s.tpUbwcRestartRequired) {
if (s.canRestartApp) ActionRow(stringResource(R.string.gpu_ubwc_restart), null, stringResource(R.string.gpu_restart_now), a.onRestartApp)
else Note(stringResource(R.string.gpu_ubwc_restart))
}
}
ChoiceRow(
host, "orientation", stringResource(R.string.setup_session_orientation), stringResource(R.string.setup_session_orientation_hint),
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
@@ -502,6 +516,8 @@ internal fun SetupPanel(
ToggleRow(host, "dual-screen-controls", stringResource(R.string.setup_dual_screen_controls),
stringResource(R.string.setup_dual_screen_controls_hint), s.dualScreenControls) { a.onDualScreenControls(it) }
}
ToggleRow(host, "android-text-field-keyboard", stringResource(R.string.setup_android_text_field_keyboard),
null, s.androidTextFieldKeyboard) { a.onAndroidTextFieldKeyboard(it) }
}
}
}
@@ -835,6 +851,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
)
val appSync = syncLabels[com.droiddeck.launcher.session.SessionPrefs.syncBackend(appContext)]
val useDefault = stringResource(R.string.comp_game_use_general)
val generalFex = com.droiddeck.launcher.core.FexPreset.byId(com.droiddeck.launcher.session.SessionPrefs.fexPreset(appContext))
val custom = stringResource(R.string.comp_game_fex_custom)
val customSync = stringResource(R.string.game_sync_custom)
val resetDone = stringResource(R.string.comp_game_reset_done, game.name)
@@ -849,7 +866,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
MenuItem(
stringResource(R.string.fex_preset_title), checked = false, enabled = ready,
detail = when {
fex == null -> useDefault
fex == null -> "$useDefault · ${stringResource(generalFex.label)}"
chosen == null -> custom
else -> stringResource(chosen.label)
},
@@ -881,7 +898,7 @@ internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.fron
}
}
AnchoredMenu(host.open == fexKey, onDismiss = { if (host.open == fexKey) host.open = null }, title = stringResource(R.string.fex_preset_title)) { first ->
MenuItem(useDefault, checked = fex == null, enabled = ready, focusRequester = first) {
MenuItem(useDefault, checked = fex == null, enabled = ready, detail = stringResource(generalFex.label), focusRequester = first) {
host.open = key
save { com.droiddeck.launcher.session.GameEnvironmentStore.setGameFexPreset(it, scope, null) }
}
@@ -56,6 +56,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
@@ -150,6 +151,27 @@ internal fun Chip(t: String, ok: Boolean) {
@Composable
internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
@Composable
internal fun DialogToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val flip = { onChange(!checked) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12)
.hoverable(src)
.clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip)
.controllerConfirm(onClick = flip)
.padding(start = 14.dp, end = 4.dp),
) {
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
ToggleSwitch(checked, label = label, modifier = Modifier.focusProperties { canFocus = false }) { onChange(it) }
}
}
@Composable
internal fun PrimaryButton(
text: String,
@@ -408,11 +408,13 @@ internal fun GameActions(
!cloud && (game != null || installed != null) -> {
val gone = storeKey?.let { StoresState.uninstalling[it] }
val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id)
val preparing = (game?.launchKey ?: storeKey)?.let { it in s.preparingGames } == true
PrimaryButton(
stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
stringResource(if (preparing) R.string.store_starting else R.string.stores_play),
enabled = !s.busy && !preparing && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn,
) {
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a)
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(store, id, s, a)
}
if (game != null) {
if (compactMenu) GameMoreMenu(game, a) else {
@@ -808,11 +810,17 @@ private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActi
val host = rememberMenuHost()
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game))
if (canEditSteamLaunchOptions(game, s.shortcutPicker)) {
SteamLaunchOptionsPanel(game.appId)
}
val folder = game.gameFiles
if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder)
}
}
internal fun canEditSteamLaunchOptions(game: Library.SteamGame, shortcutPicker: Boolean): Boolean =
!shortcutPicker && game.source == Library.SOURCE_STEAM && game.library != Library.ADDED
@Composable
private fun ShortcutFields(game: Library.SteamGame, folder: File) {
val ctx = LocalContext.current
@@ -866,12 +874,15 @@ private fun SavesTab(g: Library.SteamGame, prefix: File, a: FrontEndActions) {
@Composable
private fun AddonsTab(g: Library.SteamGame, targets: List<WowAddons.Target>, a: FrontEndActions) {
var browsing by remember(g.gameId) { mutableStateOf<WowAddons.Target?>(null) }
browsing?.let { AddonBrowserDialog(targets, it) { browsing = null } }
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
targets.forEach { target ->
val names by produceState(emptyList<String>(), target.addOns, WowAddons.imports) {
value = withContext(Dispatchers.IO) { WowAddons.installed(target) }
}
if (targets.size > 1) SectionLabel(target.flavor)
MenuItem(stringResource(R.string.addons_download), checked = false) { browsing = target }
MenuItem(
stringResource(R.string.games_import_addons), checked = false,
detail = pluralStringResource(R.plurals.games_addons_count, names.size, names.size),
@@ -1,7 +1,6 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
@@ -134,8 +133,6 @@ class GpuDriversState(
val runtime: DriverSlot = DriverSlot(),
val canRestart: Boolean = true,
val checkedAt: Long = 0L,
val tpUbwcMode: String? = null,
val tpUbwcRestartRequired: Boolean = false,
) {
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
}
@@ -156,7 +153,6 @@ class GpuDriversActions(
val onRestoreBundled: () -> Unit = {},
val onMode: (String) -> Unit = {},
val onRestart: () -> Unit = {},
val onTpUbwcMode: (String) -> Unit = {},
)
@Composable
@@ -250,45 +246,14 @@ internal fun GpuDriversBody(
Triple(GPU_MODE_PAIR, R.string.gpu_mode_pair, R.string.gpu_mode_pair_detail),
Triple(GPU_MODE_MIX, R.string.gpu_mode_mix, R.string.gpu_mode_mix_detail),
)
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
val (id, title, detail) = modes[i]
PanelRow(
stringResource(title), stringResource(detail), gpu.mode == id,
modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier)
.then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier),
onClick = { if (gpu.mode != id) actions.onMode(id) },
)
}
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
ModeChoices(modes, gpu.mode, single, first, onPick = actions.onMode) { i ->
if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier
}
when (gpu.mode) {
GPU_MODE_AUTO -> AutoBody(gpu, actions)
GPU_MODE_PAIR -> PairBody(gpu, actions)
else -> MixBody(single, actions, slotFocus, onOpenSlot)
}
gpu.tpUbwcMode?.let { selected ->
var advanced by rememberSaveable { mutableStateOf(false) }
SecondaryButton(stringResource(R.string.gpu_advanced) + if (advanced) " ▴" else " ▾", modifier = Modifier.fillMaxWidth()) { advanced = !advanced }
AnimatedVisibility(advanced) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ChoiceRow(
rememberMenuHost(), "tp-ubwc", stringResource(R.string.gpu_ubwc_title),
stringResource(R.string.gpu_ubwc_hint),
listOf(
SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
), selected, onPick = actions.onTpUbwcMode,
)
if (gpu.tpUbwcRestartRequired) {
Text(stringResource(R.string.gpu_ubwc_restart), fontSize = 13.sp, color = AttentionAmber)
if (gpu.canRestart) SecondaryButton(stringResource(R.string.gpu_restart_now), onClick = actions.onRestart)
}
}
}
}
}
}
@@ -439,12 +404,35 @@ internal const val SLOT_DISPLAY = "panel"
internal const val SLOT_RUNTIME = "rt"
@Composable
private fun PanelHeading(text: String) {
internal fun ModeChoices(
modes: List<Triple<String, Int, Int>>,
selected: String,
single: Boolean,
first: FocusRequester,
onPick: (String) -> Unit,
extra: (Int) -> Modifier = { Modifier },
) {
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
val (id, title, detail) = modes[i]
PanelRow(
stringResource(title), stringResource(detail), selected == id,
modifier = mod.then(if (selected == id) Modifier.focusRequester(first) else Modifier).then(extra(i)),
onClick = { if (selected != id) onPick(id) },
)
}
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
}
}
@Composable
internal fun PanelHeading(text: String) {
Text(text.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun PanelTitle(title: String, hint: String) {
internal fun PanelTitle(title: String, hint: String) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(hint, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -461,7 +449,7 @@ private fun Pill(text: String, fill: Color, ink: Color) {
}
@Composable
private fun PanelRow(
internal fun PanelRow(
title: String,
detail: String,
selected: Boolean,
@@ -0,0 +1,136 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.input.*
@Composable
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.(InputAction) -> Unit)? = null,
click: Boolean = false,
) {
val context = LocalContext.current
val host = rememberMenuHost()
var draft by remember(current, title) { mutableStateOf(current) }
val drafts = remember(current, title) { mutableMapOf<String, InputAction>() }
fun typeOf(action: InputAction): String = when (action) {
is InputAction.Native -> "native"; InputAction.Disabled -> "disabled"
is InputAction.MouseButton -> "mouse"; is InputAction.Command -> "command"
is InputAction.Pointer -> "pointer"; is InputAction.Scroll -> "scroll"
is InputAction.Directions -> "directions"; is InputAction.Keys -> "keys"
}
var localType by remember(current, title) { mutableStateOf(typeOf(current).takeUnless { it == "native" || it == "disabled" } ?: "keys") }
fun switch(type: String, initial: () -> InputAction) {
drafts[typeOf(draft)] = draft
draft = drafts[type] ?: initial()
if (type != "native" && type != "disabled") localType = type
}
var keys by remember { mutableStateOf(false) }
var direction by remember { mutableStateOf<Int?>(null) }
fun valid(action: InputAction) = if (click) action.validForClick() else if (element != null) action.validFor(element) else action.validFor(control)
val nativeOptions = remember(control, element, nativeTargets, click) {
(listOf(InputAction.Native()) + if (nativeTargets) InputControl.entries.map { InputAction.Native(it) } else emptyList()).filter(::valid)
}
val route = when (draft) { is InputAction.Native -> 0; InputAction.Disabled -> 2; else -> 1 }
BackHandler(onBack = onBack)
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, 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) {
0 -> switch("native") { nativeOptions.firstOrNull() ?: InputAction.Disabled }
2 -> switch("disabled") { InputAction.Disabled }
else -> switch(localType) { InputAction.Keys(listOf(28)) }
}
}
Spacer(Modifier.height(16.dp))
when (route) {
0 -> SettingsGroup(stringResource(R.string.input_route_controller)) {
ChoiceRow(host, "native-target", stringResource(R.string.input_remap_native), stringResource(R.string.input_controller_route_hint),
nativeOptions.map { it to (it.target?.label ?: stringResource(if (nativeTargets) R.string.input_native else R.string.input_normal_binding)) },
draft as InputAction.Native) { draft = it }
}
2 -> Text(stringResource(R.string.input_disabled_hint), Modifier.padding(16.dp))
else -> {
val types = listOf("keys" to stringResource(R.string.input_keys), "mouse" to stringResource(R.string.input_mouse_click),
"command" to stringResource(R.string.input_command)) + if (control.dimensions == 2) listOf(
"pointer" to stringResource(R.string.input_pointer), "scroll" to stringResource(R.string.input_scroll),
"directions" to stringResource(R.string.input_direction_keys)) else emptyList()
val type = when (draft) { is InputAction.MouseButton -> "mouse"; is InputAction.Command -> "command";
is InputAction.Pointer -> "pointer"; is InputAction.Scroll -> "scroll"; is InputAction.Directions -> "directions"; else -> "keys" }
SettingsGroup(stringResource(R.string.input_action)) {
ChoiceRow(host, "action-type", stringResource(R.string.input_action), null, types, type) {
switch(it) { when (it) { "mouse" -> InputAction.MouseButton(272); "command" -> InputAction.Command(InputCommand.MENU);
"pointer" -> InputAction.Pointer(); "scroll" -> InputAction.Scroll();
"directions" -> InputAction.Directions(103, 106, 108, 105); else -> InputAction.Keys(listOf(28)) } }
}
if (!click && draft !is InputAction.Pointer && draft !is InputAction.Scroll && draft !is InputAction.Directions) {
val hint = when {
control.isTrackpad -> R.string.input_contact_action_hint
control.dimensions == 2 -> R.string.input_axis_action_hint
control == InputControl.LT || control == InputControl.RT -> R.string.input_trigger_action_hint
else -> null
}
hint?.let { Text(stringResource(it), Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
when (val action = draft) {
is InputAction.Keys -> ActionRow(stringResource(R.string.input_keys), actionLabel(action), stringResource(R.string.ctrl_edit), { keys = true })
is InputAction.MouseButton -> ChoiceRow(host, "mouse-button", stringResource(R.string.input_mouse_click), null,
(272..274).map { it to actionLabel(InputAction.MouseButton(it)) }, action.code) { draft = InputAction.MouseButton(it) }
is InputAction.Command -> ChoiceRow(host, "command", stringResource(R.string.input_command), null,
InputCommand.entries.map { it to commandLabel(it) }, action.command) { draft = InputAction.Command(it) }
is InputAction.Pointer -> {
Text(stringResource(R.string.input_pointer_speed, action.speed.toInt()), Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurface)
Slider(action.speed, { draft = action.copy(speed = it) }, valueRange = 100f..3000f, modifier = Modifier.padding(horizontal = 16.dp))
}
is InputAction.Scroll -> {
ChoiceRow(host, "scroll-axis", stringResource(R.string.input_scroll_axis), null,
listOf(false to stringResource(R.string.input_vertical), true to stringResource(R.string.input_horizontal)), action.horizontal) {
draft = action.copy(horizontal = it)
}
Text(stringResource(R.string.input_scroll_speed, action.speed.toInt()), Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.onSurface)
Slider(action.speed, { draft = action.copy(speed = it) }, valueRange = 1f..60f, modifier = Modifier.padding(horizontal = 16.dp))
}
is InputAction.Directions -> {
ToggleRow(host, "direction-diagonals", stringResource(R.string.input_diagonal_keys), stringResource(R.string.input_diagonal_keys_hint), action.diagonals != null) {
draft = action.copy(diagonals = if (it) action.diagonals ?: listOf(0, 0, 0, 0) else null)
}
val codes = listOf(action.up, action.right, action.down, action.left) + action.diagonals.orEmpty()
val arrows = listOf("↑", "→", "↓", "←", "↗", "↘", "↙", "↖")
codes.forEachIndexed { i, code ->
ActionRow(arrows[i], if (code == 0) stringResource(if (i < 4) R.string.input_disabled else R.string.input_diagonal_neighbors) else pcKeyLabel(context, code), stringResource(R.string.ctrl_edit), { direction = i })
}
}
else -> Unit
}
}
}
}
}
if (keys) KeyCombinationPicker(onDismiss = { keys = false }, initial = (draft as? InputAction.Keys)?.codes.orEmpty(),
sequence = true, sequenceLimit = 8, title = stringResource(R.string.input_keys), confirm = stringResource(R.string.gesture_save_changes), onAdd = {
draft = InputAction.Keys(it.sortedBy { code -> if (code in PC_MODIFIERS) 0 else 1 }); keys = false
})
direction?.let { at ->
val action = draft as? InputAction.Directions ?: return@let
val codes = listOf(action.up, action.right, action.down, action.left) + action.diagonals.orEmpty()
KeyCombinationPicker(onDismiss = { direction = null }, initial = listOf(codes[at]).filter { it != 0 }, sequence = true, sequenceLimit = 1, allowEmpty = true,
title = stringResource(R.string.input_direction_keys), confirm = stringResource(R.string.gesture_save_changes), onAdd = {
val next = codes.toMutableList().also { values -> values[at] = it.firstOrNull() ?: 0 }
draft = InputAction.Directions(next[0], next[1], next[2], next[3], if (action.diagonals != null) next.drop(4) else null); direction = null
})
}
}
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more