Compare commits

...
Author SHA1 Message Date
Resul Övür a74ff3d67a fix: cancelling a paste stops the copy and keeps a move's source (#1029) 2026-10-20 20:05:05 -04:00
Juan Martí 92444946ea fix: open game Components on the game's profile (#1146) 2026-10-20 19:31:17 -04:00
Resul Övür 79889755fd fix: paste bar ignores taps while a paste is running (#1025) 2026-10-20 19:22:33 -04:00
Resul Övür ae1f90aaf0 fix: ignore uinput-* system virtual input devices in controller detection (#954) 2026-10-20 18:39:35 -04:00
Resul Övür e8595638fc fix: empty App ID no longer crashes the game environment editor (#1014) 2026-10-20 18:31:27 -04:00
Resul Övür 0a92e0abf2 fix: a slow folder listing no longer replaces the folder being shown (#1028) 2026-10-20 18:31:21 -04:00
Resul Övür 13cb2068f0 fix: file manager selection actions respect the search filter (#1021) 2026-10-20 18:31:14 -04:00
MaxsTechReview c44c12488e fix: Mono P/Invoke readv calls reach the controller hook too (#1227) 2026-10-20 18:18:10 -04:00
Resul Övür e5b44f95f6 fix: moving a stick no longer releases a D-pad or trigger held as a key (#1060) 2026-10-20 18:17:37 -04:00
maxjivi05 cbc113ea3e fix: Mono P/Invoke readv calls reach the controller hook too 2026-10-20 18:02:29 -04:00
maxjivi05 69054b1962 Merge branch 'main' into fix/readv-follow-up 2026-10-20 18:02:29 -04:00
Resul Övür 276bfd7fa2 fix: file provider rejects document ids with . or .. segments (#1016) 2026-10-20 17:58:36 -04:00
Charan Rathore 5f9acf51d4 fix: treat Unreal bootstrap launcher PE name as junk (#1020) 2026-10-20 17:52:47 -04:00
Delete228 975d6e260a fix: restore Factorio ARM64 controller input through readv (#1195) 2026-10-20 17:52:40 -04:00
Resul Övür 606ba5a9db fix: shared session logs are always scrubbed, whatever their size (#1065) 2026-10-20 17:16:36 -04:00
MaxsTechReview 06a6fd96db fix: Rewired games see DroidDeck's pads (udev listing, dlsym-resolved libc calls) (#1222) 2026-10-20 16:53:37 -04:00
Resul Övür 9139524015 fix: added-games scan keeps games like Reach, Breach, Crashday and Observer (#1023) 2026-10-20 16:53:29 -04:00
Resul Övür 019a3700ff fix: Steam Touch labels no longer corrupt the controller config (#1017) 2026-10-20 16:53:22 -04:00
Kurt HimebauchandDetinator10 782bff4e87 fix: let the device sleep while a session is suspended (#1220)
* fix: stop the DirectAudio output while the sink is suspended

* fix: verify every session thread before reporting suspension

* fix: freeze PRoot first and stop each guest thread on suspend

---------

Co-authored-by: Detinator10 <[email protected]>
2026-10-20 16:43:09 -04:00
MaxsTechReview 6a0671d5bd fix: leave the PC keyboard's pad handling to #956 and fix the red keyboard test (#1221) 2026-10-20 16:30:51 -04:00
Resul Övür ae6e4a3f80 fix: preserve Electric contrast for filled and text buttons (#1149) 2026-10-20 15:45:08 -04:00
kingokksa 3ecae47966 feat: uninstall or remove a game from the Games page (#1190) 2026-10-20 15:37:21 -04:00
Resul Övür 4969f31574 fix: hide on-screen controls while the PC keyboard is open (#956) 2026-10-20 15:06:38 -04:00
Kurt Himebauch 49446c7f01 feat: launch Easy Anti-Cheat games offline when they support it (#1039) 2026-10-20 18:58:56 +00:00
MaxsTechReview 1e33eee3f2 fix: native Linux games under FEX reach FEXServer, and a refused compat app no longer loops the sync (#1213)
FEX launched through a script (Valve's _v2-entry-point) was judged 32-bit, so the
fast path stayed off in the FEX client while FEXServer bound its socket through it.
The fast path now follows #! lines before judging the program.

The live compat sync drops apps the client refuses GetCompatTools for instead of
reattaching forever.
2026-10-20 14:58:30 -04:00
MaxsTechReview 1f71cbd117 fix: the on-screen pad steps aside while Steam's or DroidDeck's keyboard is open (#1217) 2026-10-20 14:29:50 -04:00
MaxsTechReview 55d5e2191c fix: a store game's first launch creates its prefix before the cloud save download (#1215) 2026-10-20 14:11:46 -04:00
MaxsTechReview 11892c7ad1 fix: a session on an external display stays awake while the phone screen is off (#1211) 2026-10-20 14:11:42 -04:00
MaxsTechReview 0f1b82aa26 Work around Android's denial of executable stacks (#1208) 2026-10-20 12:44:49 -04:00
Kurt Himebauch 8b279001a5 fix: prefetch Comet and Xbox packs for existing users (#1204) 2026-10-20 16:29:08 +00:00
Kurt Himebauch 61254786e6 feat: ship proot, DirectAudio, Xbox, Comet and hand tracking as CI-built add-on packs (#1198)
* perf: download hand tracking models and native libs on first use

* feat: build and publish add-on packs in CI, add Xbox and Comet packs

* feat: ship proot and DirectAudio drivers as CI-built packs

* test: hand tracking readiness requires the tracking pack

* ci: attach PR test-build packs to the PR release instead of Components
2026-10-20 11:18:44 -04:00
Kurt Himebauch ece1df0da8 Fix Game Pass library sync when subscription plan rows are omitted (#1193) 2026-10-20 11:14:06 -04:00
kingokksa 49d1311808 feat: arrange and hide/show store tabs from stores settings (#1121) 2026-10-20 11:12:27 -04:00
Delete228 197d31c2ed test: cover controller destination faults and partial reads
Exercise keyframe and ring reads with unmapped, PROT_NONE, read-only and page-boundary destinations. Verify complete-event progress within one vector and across vectors, including a NULL destination. The Linux fakeinput suite passes 11 tests with 3 i386 skips; both native ARM64 probes pass on the Thor.
2026-10-20 17:55:25 +03:00
Delete228 c0359d1c8c fix: safely copy controller events to caller buffers
Stage ring and keyframe events before copying them with process_vm_writev. Return EFAULT for inaccessible destinations and advance the reader only for complete events delivered, preserving partial readv progress. Retested Factorio on the Thor with the exact ARM64 library.
2026-10-20 17:53:40 +03:00
MaxsTechReview a38514afa0 fix: RetroArch fills the screen from the emulator rail (#1194) 2026-10-20 10:38:52 -04:00
Delete228 a17a40db02 test: reject inaccessible readv descriptor arrays
Cover unmapped, PROT_NONE, and partially accessible iovec arrays. The probe crashes with SIGSEGV before the descriptor-copy fix and returns EFAULT afterward. The Linux fakeinput suite and native ARM64 probe pass; Factorio was manually retested on the Thor with the updated library.
2026-10-20 17:34:14 +03:00
Delete228 c4298e73b4 fix: safely copy readv descriptors before validation
Copy the caller's iovec array through process_vm_readv before inspecting it, so unmapped, protected, or partially accessible arrays return EFAULT instead of crashing the process. Verified valid and invalid copies in the Android guest.
2026-10-20 17:29:42 +03:00
Delete228 02e19b2fb8 build: require the controller readv export
Check that packaged libfakeinput libraries export readv alongside the existing read hook.
2026-10-20 16:43:35 +03:00
Delete228 f817af9607 test: cover vectored controller reads
Cover readv on original and virtual controller rings, an overlay-style read-to-readv fallback, event-aligned vectors, empty vectors, nonblocking errors, and ordinary descriptor passthrough.
2026-10-20 16:42:24 +03:00
MaxsTechReview 31a6c3d033 fix: open Flatpak apps in renamed app variants (#1192)
Flatpak starts bwrap with an empty environment, so the bwrap shim lost DROIDDECK_BWRAP_SOCKET and dialled the default package's socket. The session now writes the socket name to /etc/droiddeck/bwrap-socket and the shim reads it when the variable is missing.

Fixes #1092
2026-10-20 09:38:14 -04:00
Delete228 003f4ecd3b fix: restore Factorio ARM64 controller input through readv
Steam's ARM64 overlay can fall back from read() to readv(). Route registered controller reads through the existing private ring reader so the game receives input events instead of backing-file bytes. Preserve ordinary descriptor passthrough and evdev per-vector reads.
2026-10-20 16:38:14 +03:00
FX1922 99ab6dd64e feat: Better boot movies system; Added steamdeckrepo support (#1036) 2026-10-20 09:36:25 -04:00
MaxsTechReview 9b5df0d224 perf: tile rendering for games, faster session start (#1188)
Games launched from Steam get Turnip's own per-render-pass choice between
tile memory and system memory again; TU_DEBUG=sysmem now stays with the
Steam client, whose interface it was set for. A per-game TU_DEBUG, the
debug override file and the Adreno 710/720 driver still win. On the
REDMAGIC tablet (Adreno 840) PEAK runs 46.8 -> 52.3 fps over 7 alternating
pairs and Valheim's 3D menu needs 12.6 % less GPU time per frame; light
games are unchanged.

droiddeck-seed-redists remembers the identity (inode, size, mtime) of each
prefix registry it has verified and skips re-reading it while it is
unchanged. Steam started after it on every session, ~1.8 s later.

SessionFiles keeps a ledger of the files it staged for the installed APK
and copies only the ones that changed, ~180 ms less per session start.

Session start to Steam ready: 5.57 -> 3.57 s.
2026-10-20 09:19:46 -04:00
MaxsTechReview aeb0fa57e3 fix: RPCS3 opens from the emulator rail again (#1191)
RPCS3's AppImage could not start: its own runtime unpacks the image with
a dwarfsextract it runs from a memfd, which Android refuses (EACCES), and
it has no other way to run it. Emulator AppImages for arm64 now start
through the shipped uruntime, which falls back to a file in /tmp.

RPCS3 then stopped with "Null object" in utils::shm (errno 13): it reads
/proc/sys/vm/overcommit_memory to pick memfd for guest memory, and apps
cannot read it. The guest now gets the kernel's default policy (0) there
when the real file is unreadable, like file-max.
2026-10-20 08:52:31 -04:00
MaxsTechReview ba582dea65 fix: add a picked exe the session cannot see by binding its folder (#1173)
Adding a game from a single exe outside internal storage and the game
library, such as a microSD card, only showed "This location is not
available in the session" and added nothing. #1153 made "Add all games in
this folder" bind such a folder under /root/Games, but a picked exe still
had no guest path.

addExe now binds the folder the same way when the session cannot see the
exe (the game it belongs to, or else the exe's own folder), then adds the
game. The bind is mounted from the next session start.
A folder the session already sees is not bound again, and a folder the app
cannot read is still refused.
2026-10-20 00:45:56 -04:00
MaxsTechReview 8074ec44e8 fix: route ALSA to PulseAudio for x86 Linux games under FEX (no game audio) (#1166)
fix: route ALSA to PulseAudio for x86 Linux games under FEX
2026-10-20 00:44:56 -04:00
MaxsTechReview fdab3cea82 fix: GOG games miss the registry entries their install script sets (#1159)
A GOG download only places the files, so games that look up their own registry keys (Fallout: New Vegas reads HKLM\Software\Bethesda Softworks\FalloutNV "Installed Path") said they were not installed. Every GOG launch now reads the setRegistry actions of the game's goggame-<id>.script and imports them into the prefix's 32-bit view through Proton's reg.exe, once per change of the script, so games already downloaded are fixed on their next launch.
2026-10-20 00:38:08 -04:00
MaxsTechReview b26b43fbd0 fix: find and save Steam touch layouts of non-Steam shortcuts (#1171)
The touch layout editor opened "App -<n> Layout" for a non-Steam shortcut
(an added game, the Epic Games Launcher) with LB, LT, RT, RB, both sticks
and LS/RS greyed out, so they could not be placed. Steam keys a shortcut's
controller config by the shortcut's lowercase name and logs its app id
unsigned, but the editor looked both up by the signed app id, found
neither, and fell back to a blank layout with nothing bound.

SteamTouchConfig now reads the shortcut's name from shortcuts.vdf, loads
and saves its layout under that name the way Steam does (autosave folder
and configset entry), and matches Steam's log line by the unsigned id. The
editor is titled with the game's name, and the app id in the panel is
shown unsigned. A name that is not a plain folder name (a
path separator, "." or "..", a quote, a backslash or a control character)
keeps the numeric app id. Existing configset entries are matched
without regard to case, as Steam reads them.
2026-10-20 00:34:01 -04:00
MaxsTechReview 4ba3e442a8 fix: let Wine write hooks into code FEX has translated (Ubisoft "Error at hooking API") (#1163)
fix: let Wine write hooks into code FEX has translated

Wine 11's wineserver writes another process's memory with process_vm_writev,
which the kernel refuses on a page that is not writable. FEX's WoW64 layer
keeps the pages it has translated read-only to catch self-modifying code and
re-protects them right after a program unprotects them, so WriteProcessMemory
and NtWriteVirtualMemory into ntdll failed with a partial copy and Ubisoft
games stopped at "Error at hooking API NtProtectVirtualMemory". Wine only
falls back to /proc/<pid>/mem on ENOSYS or EPERM.

libblsession now completes a single-buffer write that wineserver was refused
with EFAULT through /proc/<pid>/mem, the way Wine wrote before Wine 11. Other
programs and every other error keep the kernel's answer. The iovecs are read
with process_vm_readv, so a bad pointer still returns EFAULT, and offsets are
64-bit in the i386 build.
2026-10-20 00:31:03 -04:00
MaxsTechReview 57799bef5a fix: add games picked inside a folder game as their own games (#1155)
Picking an exe anywhere under an added folder treated it as that game, so a
folder added with "Add all games in this folder" (or a removed one) swallowed
every game below it: picking another game's exe switched the folder game's
target, or brought the removed folder game back under its old name and art.

Only treat a pick as the same game when it sits in the folder root, in the
same top-level subfolder as the game's current exe, or is another build of
that exe, and prefer the deepest matching folder.
2026-10-20 00:29:21 -04:00
MaxsTechReview cee84fd12a fix: show the app's cursor in sessions started from the front end (#1154)
gamescope makes its cursor surfaces by attaching an shm buffer, committing,
destroying the buffer and only then calling wl_pointer.set_cursor. The
compositor kept that buffer only as a reference, so set_cursor found nothing
to publish: apps run from the front end (store apps, Flatpaks, emulators)
showed DroidDeck's fallback arrow, or no cursor at all after a desktop
session had hidden its own.

Copy a role-less surface's pixels when its held buffer is destroyed and
publish that copy when the surface becomes the cursor, for as long as the
surface lives.
2026-10-20 00:28:14 -04:00
Resul Övür 48daa064c9 fix: confirm before bulk-removing unused components in Storage (#1140) 2026-10-19 23:51:11 -04:00
Resul Övür a42ad82bcb fix: keep held Steam Touch controls pressed across action-set and layer changes (#1143) 2026-10-19 23:50:29 -04:00
Kurt Himebauch 06e819f864 fix: an interrupted package install can no longer break the Linux runtime (#1078) 2026-10-19 23:40:30 -04:00
Grimarius 4c81c98f12 feat: add configurable touchscreen gestures (#940) 2026-10-20 03:22:41 +00:00
MaxsTechReview 3a8d6aaabb fix: Steam no longer sleeps again right after a paused session resumes (#1162) 2026-10-19 23:06:48 -04:00
Resul Övürandovurrsl 64b966af49 fix: keep ModeHeader search from stealing focus when recomposed (#1141)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:22 -04:00
Resul Övürandovurrsl 583c6805fd fix: keep touchpad two-finger scroll from jumping the cursor or starting a drag (#1142)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:06 -04:00
Resul Övür e8ee11612f fix: uppercase section headings in the app language (#1148)
* fix: uppercase section headings in the app language

* fix: localize store section headings
2026-10-19 21:27:50 -04:00
MaxsTechReview 7855bec6a1 fix: give x86 programs under FEX the runtime's OpenSSL 3 (libcrypto.so.3 not found) (#1167)
fix: give x86 programs under FEX the runtime's OpenSSL 3
2026-10-19 21:27:25 -04:00
MaxsTechReview 2a423a729a fix: let x86 apps under FEX use the device's fonts (Epic launcher CJK text shows boxes) (#1168)
fix: let x86 apps under FEX use the device's fonts for CJK text
2026-10-19 21:27:18 -04:00
MaxsTechReview 01f41e0553 fix: user apps open at the Desktop's resolution (#1172)
A user app (script, AppImage or Flatpak) started from its shortcut runs in
a Run session, which took the Steam resolution (720p by default). It
ignored the resolution chosen under Open Desktop and was letterboxed at
1280x720 on screens that are not 16:9.

User apps now use the Desktop's resolution once one has been chosen. Until
then, and for every other Run program, nothing changes.
2026-10-19 21:27:06 -04:00
Kurt Himebauch 3dd0045874 Restore Xbox launches from Steam (#1170) 2026-10-20 00:46:35 +00:00
Kurt Himebauch 6bbcf3ad8b Restrict Xbox credential IPC and address launch review findings (#1164)
* Restrict Xbox credentials to host backends and lease game launches

* Verify mapped PRoot backend images and retry lease heartbeat timeouts

* Bound Xbox backend shutdown and isolate service socket lifetimes

* Keep unique Xbox service sockets within Unix address limits

* Own licensed launch directories and recover abandoned views safely

* Serialize Xbox account revocation with sign-in commits and launch authorization
2026-10-19 20:10:54 -04:00
267 changed files with 19004 additions and 859 deletions

No files matched your search

+59 -10
View File
@@ -34,10 +34,28 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "[email protected]"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: xbox-cache
with:
@@ -80,6 +98,7 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Check co-authors
if: github.event_name == 'pull_request'
@@ -125,14 +144,17 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "[email protected]"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -179,6 +201,11 @@ jobs:
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -227,8 +254,6 @@ jobs:
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
@@ -265,7 +290,7 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
@@ -368,18 +393,17 @@ jobs:
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
- name: Bundle the droiddeck-esync packs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -f tools/droiddeck-esync/release.env ]; then
. tools/droiddeck-esync/release.env
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
mkdir -p app/src/main/assets/droiddeck-esync
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
rm sync-bundle.tzst
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
for id in $revoked; do
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
@@ -515,6 +539,9 @@ jobs:
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
- name: Build
id: build
env:
@@ -553,6 +580,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
@@ -613,6 +645,14 @@ jobs:
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
@@ -715,8 +755,17 @@ jobs:
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
packs:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
secrets: inherit
announce:
needs: [build, sign]
needs: [build, sign, packs]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
+22
View File
@@ -176,6 +176,27 @@ jobs:
pattern: droiddeck-pr-signed-*
merge-multiple: true
path: signed
- name: The add-on packs main has not published
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ needs.authorize.outputs.run_id }}
run: |
set -euo pipefail
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
mkdir -p pr-packs
for file in packs/*.tar.zst; do
name=$(basename "$file" .tar.zst)
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty')
if [ "$digest" = "sha256:$sha" ]; then
echo "$name: served from DroidDeck-Components $tag"
else
cp "$file" "pr-packs/$tag.tar.zst"
echo "$name: attached to the PR release as $tag.tar.zst"
fi
done
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
@@ -233,6 +254,7 @@ jobs:
first=true
fi
echo "first=$first" >> "$GITHUB_OUTPUT"
cp pr-packs/* signed/ 2>/dev/null || true
gh release upload "$tag" signed/* -R "$repo" --clobber
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
keep=$(cd signed && ls | jq -R . | jq -sc .)
+83
View File
@@ -0,0 +1,83 @@
name: Publish add-on packs
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
# Only main and release builds publish here; a PR test build attaches the packs main has not published
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
on:
workflow_call:
inputs:
run_id:
description: The Build APK run whose packs to publish; empty for the calling run's own
type: string
default: ''
permissions:
contents: read
actions: read
jobs:
publish:
name: publish add-on packs
runs-on: ubuntu-24.04
timeout-minutes: 15
concurrency:
group: publish-packs
cancel-in-progress: false
steps:
- name: The packs (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-packs
path: packs
- name: The packs (another Build APK run)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
- uses: actions/create-github-app-token@v2
id: components-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
REPO: Droid-Deck/DroidDeck-Components
BUILD_RUN: ${{ inputs.run_id || github.run_id }}
run: |
set -euo pipefail
published_digest() {
{ gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$(published_digest "$tag" "$name.tar.zst")
if [ -z "$digest" ]; then
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|| echo "::warning::creating $tag failed; checking whether another run published it"
digest=$(published_digest "$tag" "$name.tar.zst")
fi
if [ "$digest" != "sha256:$sha" ]; then
echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha"
exit 1
fi
echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
done
+11 -1
View File
@@ -63,9 +63,19 @@ jobs:
artifact: droiddeck-release
secrets: inherit
packs:
needs: find
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
with:
run_id: ${{ needs.find.outputs.run_id }}
secrets: inherit
publish:
name: publish the release
needs: [find, sign]
needs: [find, sign, packs]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
+3 -1
View File
@@ -47,10 +47,12 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment.
+30 -16
View File
@@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
return [(name): [sha256: '', size: 0L]]
}
def digest = java.security.MessageDigest.getInstance('SHA-256')
pack.withInputStream { input ->
byte[] buffer = new byte[1 << 20]
int n
while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n)
}
[(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]]
}
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
@@ -119,6 +136,10 @@ android {
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
addonPacks.each { name, pack ->
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
}
ndk {
abiFilters 'arm64-v8a'
@@ -172,6 +193,11 @@ android {
disable 'ExpiredTargetSdkVersion'
}
androidResources {
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
}
packagingOptions {
resources {
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
@@ -179,6 +205,8 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -284,22 +312,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
def verifyHandModel = tasks.register('verifyHandModel') {
def models = ['hand_landmarker.task': 'fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1',
'hand_landmarker_lite.task': '173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8']
.collectEntries { name, hash -> [(file("src/main/assets/models/$name")): hash] }
inputs.files(models.keySet())
doLast {
models.each { model, expected ->
def actual = java.security.MessageDigest.getInstance('SHA-256').digest(model.bytes).encodeHex().toString()
if (actual != expected) throw new GradleException("Hand Landmarker model checksum mismatch: ${model.name}")
}
}
}
tasks.named('preBuild').configure { dependsOn(verifyHandModel) }
dependencies {
implementation 'org.opencv:opencv:4.13.0'
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
@@ -319,7 +333,7 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1'
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
Binary file not shown.
+163 -10
View File
@@ -21,6 +21,9 @@
#include <dirent.h>
#include <dlfcn.h>
#ifdef __GLIBC__
#include <gnu/lib-names.h>
#endif
#include <fcntl.h>
#include <linux/input.h>
#include <linux/joystick.h>
@@ -3577,7 +3580,21 @@ EXPORT int close(int fd) {
static constexpr int kMaxRingWaitMs = 2;
static constexpr int kLowLatencyRingWaitUs = 250;
EXPORT ssize_t read(int fd, void *buf, size_t count) {
static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) {
size_t bytes = count * sizeof(struct input_event);
struct iovec local = {const_cast<struct input_event *>(events), bytes};
struct iovec remote = {buf, bytes};
ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0);
if (copied < 0) return -1;
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
copied -= copied % sizeof(struct input_event);
if (!copied) return -1;
}
return copied;
}
static ssize_t read_input(int fd, void *buf, size_t count) {
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto controller = controller_map().find(fd);
@@ -3622,42 +3639,44 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
if (events == 0) break;
const uint8_t *ring_events = reinterpret_cast<const uint8_t *>(fake.ring) +
FAKE_INPUT_RING_HEADER_SIZE;
std::vector<struct input_event> input_events(events);
for (size_t i = 0; i < events; ++i) {
size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY;
RingEvent stored;
memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
struct input_event ev = {};
struct input_event &ev = input_events[i];
ev.time.tv_sec = stored.sec;
ev.time.tv_usec = stored.usec;
ev.type = stored.type;
ev.code = stored.code;
ev.value = stored.value;
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
}
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED))
continue;
fake.read_seq += events;
return static_cast<ssize_t>(events * sizeof(struct input_event));
ssize_t copied = copy_input_events(buf, input_events.data(), events);
if (copied > 0) fake.read_seq += copied / sizeof(struct input_event);
return copied;
}
}
size_t events = std::min(requested, fake.keyframe_remaining);
struct input_event input_events[kNeutralEventCount] = {};
struct timeval now = {};
gettimeofday(&now, nullptr);
for (size_t i = 0; i < events; ++i) {
size_t index = kNeutralEventCount - fake.keyframe_remaining;
struct input_event ev = {};
size_t index = kNeutralEventCount - fake.keyframe_remaining + i;
struct input_event &ev = input_events[i];
ev.time = now;
ev.type = kNeutralEvents[index].type;
ev.code = kNeutralEvents[index].code;
ev.value = keyframe_value(fake, ev.type, ev.code);
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
--fake.keyframe_remaining;
}
return static_cast<ssize_t>(events * sizeof(struct input_event));
ssize_t copied = copy_input_events(buf, input_events, events);
if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event);
return copied;
}
if (nonblock) {
errno = EAGAIN;
@@ -3673,6 +3692,52 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
}
}
EXPORT ssize_t read(int fd, void *buf, size_t count) {
return read_input(fd, buf, count);
}
EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) {
if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt);
if (iovcnt < 0 || iovcnt > IOV_MAX) {
errno = EINVAL;
return -1;
}
if (iovcnt && !iov) {
errno = EFAULT;
return -1;
}
std::vector<struct iovec> copied_iov(static_cast<size_t>(iovcnt));
if (iovcnt) {
size_t bytes = copied_iov.size() * sizeof(struct iovec);
struct iovec local = {copied_iov.data(), bytes};
struct iovec remote = {const_cast<struct iovec *>(iov), bytes};
ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0);
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
return -1;
}
}
iov = copied_iov.data();
size_t size = 0;
for (int i = 0; i < iovcnt; ++i) {
if (iov[i].iov_len > static_cast<size_t>(SSIZE_MAX) - size) {
errno = EINVAL;
return -1;
}
size += iov[i].iov_len;
}
ssize_t total = 0;
for (int i = 0; i < iovcnt; ++i) {
if (!iov[i].iov_len) continue;
ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len);
if (got < 0) return total ? total : -1;
total += got;
if (static_cast<size_t>(got) != iov[i].iov_len) break;
}
return total;
}
EXPORT ssize_t write(int fd, const void *buf, size_t count) {
static auto my_write = reinterpret_cast<decltype(&::write)>(dlsym(RTLD_NEXT, "write"));
@@ -4103,4 +4168,92 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
if (!real) return fstat64(fd, buf);
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
}
#if defined(__x86_64__) || defined(__i386__)
using dlsym_fn = void *(*)(void *, const char *);
static const char *const interposed_names[] = {
"open", "open64", "openat", "openat64", "close", "read", "readv", "write", "writev", "ioctl",
"poll", "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64",
"__fxstat", "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access",
"faccessat", "opendir", "scandir", "inotify_add_watch",
};
static dlsym_fn next_dlsym() {
static const dlsym_fn fn = [] {
#ifdef __x86_64__
const char *base = "GLIBC_2.2.5";
#else
const char *base = "GLIBC_2.0";
#endif
for (const char *version : {base, "GLIBC_2.34"}) {
if (auto found = reinterpret_cast<dlsym_fn>(dlvsym(RTLD_NEXT, "dlsym", version))) return found;
}
return dlsym_fn{};
}();
return fn;
}
static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); }
static void *missing_dlsym(void *, const char *) { return nullptr; }
static bool interposed(const char *name) {
for (const char *interposed_name : interposed_names) {
if (strcmp(interposed_name, name) == 0) return true;
}
return false;
}
extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) {
dlsym_fn next = next_dlsym();
if (!next) return reinterpret_cast<void *>(missing_dlsym);
if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast<void *>(next);
static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD);
if (handle != libc || !interposed(name)) return reinterpret_cast<void *>(next);
return reinterpret_cast<void *>(global_dlsym);
}
#ifdef __x86_64__
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"pushq %rdi\n"
".cfi_adjust_cfa_offset 8\n"
"pushq %rsi\n"
".cfi_adjust_cfa_offset 8\n"
"subq $8, %rsp\n"
".cfi_adjust_cfa_offset 8\n"
"call fakeinput_dlsym_target\n"
"addq $8, %rsp\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rsi\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rdi\n"
".cfi_adjust_cfa_offset -8\n"
"jmp *%rax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#else
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"subl $12, %esp\n"
".cfi_adjust_cfa_offset 12\n"
"movl 20(%esp), %eax\n"
"movl %eax, 4(%esp)\n"
"movl 16(%esp), %eax\n"
"movl %eax, (%esp)\n"
"call fakeinput_dlsym_target\n"
"addl $12, %esp\n"
".cfi_adjust_cfa_offset -12\n"
"jmp *%eax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#endif
#endif
#endif
+10
View File
@@ -1,5 +1,8 @@
#include <errno.h>
#include <jni.h>
#include <stdatomic.h>
#include <sys/syscall.h>
#include <unistd.h>
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
@@ -7,3 +10,10 @@ Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env,
(void)clazz;
atomic_thread_fence(memory_order_release);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_session_ThreadSignals_tgkill(JNIEnv *env, jclass clazz, jint tgid, jint tid, jint signal) {
(void)env;
(void)clazz;
return syscall(SYS_tgkill, tgid, tid, signal) == 0 ? 0 : errno;
}
+34 -1
View File
@@ -218,6 +218,8 @@ struct surface {
enum surface_role role;
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
uint32_t *idle_px;
int idle_w, idle_h;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -899,8 +901,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer)
g_cursor_shown = s;
}
static void surface_forget_idle_px(struct surface *s) {
free(s->idle_px);
s->idle_px = NULL;
s->idle_w = s->idle_h = 0;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer);
if (shm) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
int32_t stride = wl_shm_buffer_get_stride(shm);
uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4
? realloc(s->idle_px, (size_t)w * h * 4) : NULL;
if (px) {
wl_shm_buffer_begin_access(shm);
const uint8_t *src = wl_shm_buffer_get_data(shm);
for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4);
wl_shm_buffer_end_access(shm);
s->idle_px = px;
s->idle_w = w;
s->idle_h = h;
}
}
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
@@ -927,6 +951,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) {
}
static void surface_drop_idle(struct surface *s) {
surface_forget_idle_px(s);
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
@@ -1055,6 +1080,7 @@ static void surface_resource_destroy(struct wl_resource *r) {
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
surface_forget_idle_px(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -2042,6 +2068,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
} else if (s->idle_px) {
cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy);
g_cursor_shown = s;
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -2593,7 +2622,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy);
if (!(k && k->is_lock))
// A button-only input must not replay a cached absolute position. A game
// may have warped its cursor since the last motion (before taking lock,
// or while releasing it), so that replay can become an unintended pan.
// Absolute touch events still carry their position, including on clicks.
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
send_relative_motion(client, dx, dy);
for_each_pointer_of(client, sp) {
@@ -30,6 +30,7 @@ class App : Application() {
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
prefetchAddonPacks()
GpuClockPin.clearLeftover(this)
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
@@ -42,4 +43,21 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
com.droiddeck.launcher.handtracking.HandTracking.load(app)
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
}, "addon-packs").start()
}
}
@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
@@ -123,6 +124,7 @@ class MainActivity : ComponentActivity() {
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var componentsGameKey by mutableStateOf<String?>(null)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
private var onSavePicked: ((File) -> Unit)? = null
@@ -859,6 +861,7 @@ class MainActivity : ComponentActivity() {
},
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
onGameRemoved = { g -> removeGame(g) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onSgdbKey = { key ->
Thread({
@@ -920,6 +923,7 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onGameComponents = { game -> openComponents(gameKey = game.profileKey) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
@@ -1124,6 +1128,13 @@ class MainActivity : ComponentActivity() {
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
@@ -1280,6 +1291,7 @@ class MainActivity : ComponentActivity() {
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1424,8 +1436,9 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
focusComponentsContent = focusContent
componentsGameKey = gameKey
if (tab != null) components.compComp = tab
showPerformance = false
showMapping = false
@@ -1474,6 +1487,7 @@ class MainActivity : ComponentActivity() {
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,
initialGameKey = componentsGameKey,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
@@ -1533,6 +1547,27 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
@@ -1686,12 +1721,12 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
onImportBootMovie = {
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
},
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
@@ -1776,6 +1811,30 @@ class MainActivity : ComponentActivity() {
}, "steam-art-update").start()
}
private fun removeGame(g: Library.SteamGame) {
val folder = g.gameFiles ?: return
when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) {
com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit
com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name)
com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> {
val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g)
if (installed != null) {
com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() }
return
}
if (g.library == Library.ADDED) {
removeAddedGame(folder.path, g.gameId, g.name)
return
}
val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return
Thread({
com.droiddeck.launcher.frontend.GameRemoval.delete(target)
ui.post { refreshAddedGames(); refresh() }
}, "game-remove").start()
}
}
}
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
@@ -1979,10 +2038,14 @@ class MainActivity : ComponentActivity() {
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending || installed != null && release?.version == installed) {
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -81,6 +81,7 @@ import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.input.TouchscreenGestures
import com.droiddeck.launcher.input.ScrollTouchGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
@@ -191,6 +192,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var touchpad: TouchpadGestures
private lateinit var directTouchHold: DirectTouchHold
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private lateinit var touchscreenGestures: TouchscreenGestures
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var gestureSettingsOpen by mutableStateOf(false)
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
private var gesturePointerX = 0f
private var gesturePointerY = 0f
private var gesturePointerReady = false
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
private val gestureKeysDown = mutableSetOf<Int>()
private lateinit var scrollTouch: ScrollTouchGestures
private lateinit var padMouse: PadMouse
@Volatile private var padMouseOn = false
@@ -211,6 +222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
gesturePointerReady = false
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
padMouseCheckedAt = 0L
@@ -489,6 +501,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
touchscreenGestures = TouchscreenGestures(
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
)
refreshGestureConfig()
// A real touch target lets FrameLayout split fingers between the
// game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
}
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn
@@ -505,12 +527,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -598,6 +623,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onDispose { com.droiddeck.launcher.glasses.GlassesMenuMirror.setSessionMenuVisible(this@SessionActivity, false) }
}
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
DroidDeckTheme {
if (pipUi) {
@@ -640,6 +666,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
@@ -648,6 +675,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
LaunchedEffect(AgentGuest.focusRevision) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
else {
val snapshot = AgentGuest.focus(this@SessionActivity)
val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L }
val windows = snapshot?.optJSONArray("focusableApps")
focused?.toString() ?: windows?.let { values ->
(0 until values.length()).map { values.optLong(it) }
.filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString()
}
}
}
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
}
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
cancelTouchGestures()
}
}
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
)
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
@@ -683,7 +736,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
achievementsInFront = found
if (found == null) achievementsOpen = false
}
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() }
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads(); updateOnScreenControls() }
LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) {
if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset()
}
@@ -782,6 +835,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setSteamController(this@SessionActivity, id)
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
padType = id
},
backActionsInverted = backActionsInverted,
@@ -823,6 +877,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -1326,17 +1381,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
@@ -1346,6 +1402,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installRuntime()
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
}
if (problem == null && resume && !SessionState.stopRequested) {
SessionState.installing = "package"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
?.let { getString(R.string.session_package_resume_failed, it) }
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
}
if (problem == null && desktop && !SessionState.stopRequested) {
SessionState.installing = "desktop"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
@@ -1370,7 +1433,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -1418,9 +1485,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post {
loading.percent = percent
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
}
}
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return getString(R.string.session_runtime_catalog_unreachable)
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
@@ -1528,6 +1603,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
shownOnDisplayId,
)
}
applyFrameGen()
@@ -1543,6 +1619,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceW = width
surfaceH = height
if (resized) {
cancelTouchGestures()
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
followWindow(width, height)
@@ -1561,7 +1638,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun followChoice(): Boolean {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
}
@@ -1577,6 +1654,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (started) SessionService.setActivityDisplay(this, shownOnDisplayId)
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
@@ -1585,12 +1664,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Hiding the phone surface after a handoff delivers a late destruction
// callback. It must not reset the new output's size or attachment.
if (holder !== outputHolder()) return
cancelTouchGestures()
surfaceW = 0
surfaceH = 0
glassesOutputActive = false
CompositorHost.detach(holder.surface)
}
private fun cancelTouchGestures() {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
@@ -1600,8 +1688,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
private fun displayMode(): String = SessionPrefs.displayMode(this,
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
private fun outputSize(): Pair<Int, Int> {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
@@ -1721,7 +1812,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
Thread({
val hint = sessionEndHint(SessionState.logFile)
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
@@ -1740,6 +1831,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
@@ -1758,6 +1850,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.post { sessionOverlay.requestFocus() }
}
private fun repairRuntime() {
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
closeAtOnce = true
finish()
startActivity(again)
}
private fun retrySession() {
val again = Intent(intent)
closeAtOnce = true
@@ -1765,7 +1864,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
startActivity(again)
}
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
private fun runtimeDamageHint(): EndHint? {
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
if (broken.isEmpty()) return null
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
}
private fun sessionEndHint(log: File?): EndHint? {
if (log == null || !log.isFile) return null
@@ -1989,6 +2095,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.dispatchKeyEvent(event)
}
private fun refreshGestureConfig() {
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
gesturePointerReady = false
recognizedGesture = null
}
private val gestureOutput = object : TouchscreenGestures.Output {
override fun moveTo(x: Float, y: Float) {
val rect = drawnRect() ?: return
if (pointerLocked) {
if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true)
} else movePointer(x, y)
gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right)
gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom)
gesturePointerReady = true
cursorVisible = false
}
override fun moveBy(dx: Float, dy: Float, relative: Boolean) {
val rect = drawnRect() ?: return
if (relative || pointerLocked) {
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta(dx * scale, dy * scale)
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
}
override fun button(code: Int, down: Boolean) {
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
}
override fun prepareRelativePan() {
if (pointerLocked) return
val rect = drawnRect() ?: return
// Gamescope retains the last absolute position while a game can warp
// its own cursor to the center. Its first unlocked motion must start
// from that same origin, before the game switches to pointer lock.
moveTo(rect.centerX(), rect.centerY())
}
override fun key(code: Int, down: Boolean) {
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
override fun gesture(gesture: TouchscreenGestures.Gesture) {
recognizedGesture = gesture
uiHandler.removeCallbacks(clearRecognizedGesture)
uiHandler.postDelayed(clearRecognizedGesture, 1200)
}
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
@@ -2123,9 +2278,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
private val shownOnDisplayId: Int get() =
if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId
else windowManager.defaultDisplay.displayId
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
val primaryId = shownOnDisplayId
val candidates = SecondScreenDisplays.available(displayManager, primaryId)
.filter { glassesScreen == null || it.id != glasses.displayId }
val oldIds = secondScreenDisplays.map { it.id }.toSet()
@@ -2279,6 +2438,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
return when {
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
else -> SessionPrefs.TOUCH_DIRECT
}
@@ -2291,6 +2451,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
touchpad.sensitivity = 1.15f * speed
}
if (next == routedTouchMode) return
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
gesturePointerReady = false
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
@@ -2327,6 +2489,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
return touchscreenGestures.onTouch(event)
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -2504,7 +2671,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi || unfoldedPanel != null) {
if (pipUi || unfoldedPanel != null || pcKeyboardOpen) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
@@ -2536,29 +2703,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
val drawn = show && !keyboardCoversPad()
if (steam != null) {
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!show) steam.releaseAll()
steam.visibility = if (show) View.VISIBLE else View.GONE
if (!drawn) steam.releaseAll()
steam.visibility = if (drawn) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
applyTouchRouting()
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
return
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
Log.i(TAG, "on-screen controls " + when {
!show -> "hidden"
!drawn -> "hidden"
buttonsOnly -> "Steam + QAM"
else -> "full pad"
})
}
private fun keyboardCoversPad() =
SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
private fun pipBounds(): android.graphics.Rect {
@@ -2640,7 +2811,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
startedScreens++
SessionService.setActivityVisible(this, true)
SessionService.setActivityVisible(this, true, shownOnDisplayId)
Glasses.rescan()
updateGlassesScreen()
updateHandCamera()
@@ -2713,7 +2884,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
uiHandler.removeCallbacks(clearRecognizedGesture)
gestureSettingsOpen = false
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -2963,7 +3138,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun glassesOutputSize(): Pair<Int, Int>? {
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice)
@@ -3089,6 +3264,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
if (runtimeRemovalBlocked) return
if (!hasFocus) {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
}
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
@@ -3199,6 +3378,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private var startedScreens = 0
@@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import android.os.FileObserver
import android.util.Log
@@ -19,6 +20,8 @@ object AgentGuest {
private val main by lazy { Handler(Looper.getMainLooper()) }
var focusRevision by mutableIntStateOf(0)
private set
var steamKeyboard by mutableStateOf(false)
private set
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -32,6 +35,7 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -43,6 +47,7 @@ object AgentGuest {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
}
}
}.also { it.startWatching() }
@@ -53,9 +58,19 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false }
}
}
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
val showing = snapshot.optBoolean("steamKeyboard")
synchronized(lock) {
if (source !== observer) return
main.post { steamKeyboard = showing }
}
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -0,0 +1,137 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
/**
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
* was made with install, whichever host serves them.
*/
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
data object Failed : State
data object Ready : State
}
private val mutableState = MutableStateFlow<State>(State.Missing)
val state: StateFlow<State> = mutableState.asStateFlow()
private val lock = Any()
val available: Boolean get() = sha256.isNotEmpty()
private val tag get() = "pack-$name-${sha256.take(16)}"
private val urls get() = listOfNotNull(
"https://github.com/$REPO/releases/download/$tag/$name.tar.zst",
BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" },
)
private fun root(context: Context) = File(context.filesDir, "addons/$name")
/** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), sha256)
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
fun refresh(context: Context): State {
val now = mutableState.value
if (now is State.Downloading) return now
return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing)
.also { mutableState.value = it }
}
/** Starts the download in the background unless the pack is already present or on its way. */
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext
synchronized(lock) {
if (installed(app)) { mutableState.value = State.Ready; return }
if (mutableState.value is State.Downloading) return
mutableState.value = State.Downloading(0f)
}
scope.launch {
val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false)
mutableState.value = if (ok) State.Ready else State.Failed
if (ok) onReady(app)
}
}
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true
ensure(context)
while (running()) {
val now = state.value
onState(now)
when (now) {
State.Ready -> return installed(context)
State.Failed -> return false
else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false }
}
}
return false
}
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
val archive = File(root, "$sha256.tar.zst")
val downloaded = urls.any { url ->
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
.also { if (!it) archive.delete() }
}
if (!downloaded) return false
val staging = File(root, "$sha256.partial")
staging.deleteRecursively()
try {
if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false
check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" }
File(staging, COMPLETE).writeText(sha256)
val target = dir(context)
target.deleteRecursively()
check(staging.renameTo(target)) { "could not move the $name pack into place" }
} finally {
staging.deleteRecursively()
archive.delete()
}
root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() }
return true
}
companion object {
private const val TAG = "AddonPack"
private const val REPO = "Droid-Deck/DroidDeck-Components"
private const val PR_REPO = "Droid-Deck/DroidDeck-CI"
const val COMPLETE = ".complete"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@JvmField
val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE,
listOf("libproot.so", "libproot-loader.so"))
@JvmField
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
}
}
@@ -225,6 +225,9 @@ object DeviceReport {
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
k("Runtime ready", LinuxRuntime.isInstalled(context))
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
k("Runtime root", LinuxRuntime.rootDir(context).path)
h("Settings in effect")
@@ -29,6 +29,7 @@ object GameEnvironment {
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"DROIDDECK_ANTICHEAT_OFFLINE" to "1",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
"DOTNET_GCRegionRange" to "800000000",
@@ -32,6 +32,7 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
@@ -146,16 +146,21 @@ object LogRedactor {
return out
}
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
/** The at-rest rewrite reads a file into memory twice; larger logs are only scrubbed on the way out. */
const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024
fun isBinary(file: java.io.File): Boolean = try {
file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
n > 0 && (0 until n).any { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
false
true
}
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
const val RULES_VERSION = 3
private val LAN_IPV4 = Regex(
@@ -176,8 +181,62 @@ object LogRedactor {
}
}
private const val SHARE_LINE_MAX = 64 * 1024
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
src.inputStream().buffered().use { input ->
input.mark(2)
val first = input.read()
val second = input.read()
input.reset()
if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) {
out.write("<unsupported encoding; file withheld>\n")
return
}
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
java.io.InputStreamReader(input, decoder).use { reader ->
val buffer = CharArray(8192)
val line = StringBuilder()
var withheld = false
var afterCr = false
fun emit() {
out.write(if (withheld) "<unsupported or oversized line withheld>" else redactForShare(line.toString()))
out.write("\n")
line.setLength(0)
withheld = false
}
try {
while (true) {
val size = reader.read(buffer)
if (size < 0) break
for (i in 0 until size) {
val ch = buffer[i]
if (afterCr) {
afterCr = false
if (ch == '\n') continue
}
if (ch == '\r' || ch == '\n') {
emit()
afterCr = ch == '\r'
} else if (!withheld) {
// Splitting a line can separate a credential from its label.
if (ch == '\u0000' || line.length == SHARE_LINE_MAX) {
withheld = true
line.setLength(0)
} else {
line.append(ch)
}
}
}
}
if (withheld || line.isNotEmpty()) emit()
} catch (_: java.nio.charset.CharacterCodingException) {
out.write("<unsupported encoding; file withheld>\n")
}
}
}
}
private fun quoted(value: String): String {
@@ -85,7 +85,7 @@ class AppFilesProvider : DocumentsProvider() {
override fun deleteDocument(documentId: String) {
val file = fileFor(documentId)
if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
if (file.canonicalPath == baseCanonical) throw FileNotFoundException("the app folder itself is not deleted")
try {
deleteTree(file.toPath())
} catch (e: IOException) {
@@ -154,7 +154,7 @@ class AppFilesProvider : DocumentsProvider() {
private fun fileFor(documentId: String): File {
val f = File(documentId)
if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.isAbsolute || f.normalize().path != documentId || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.exists()) throw FileNotFoundException("$documentId not found")
return f
}
@@ -90,6 +90,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -151,8 +152,11 @@ fun FileManagerScreen(
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
val visibleEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
var showSearch by remember { mutableStateOf(false) }
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
@@ -174,16 +178,20 @@ fun FileManagerScreen(
var showFavorites by remember { mutableStateOf(false) }
var favTick by remember { mutableIntStateOf(0) }
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
currentDir = dir
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
scope.launch {
listingJob?.cancel()
listingJob = scope.launch {
val list = withContext(Dispatchers.IO) {
dir.listFiles()?.toList()
?.filter { if (pickDirMode) it.isDirectory else !pickMode || it.isDirectory || matchesPickExt(it) }
?.filter { showHidden || !it.name.startsWith(".") }
?.sortedWith(comparatorFor(sortBy, sortDesc)) ?: emptyList()
}
if (currentDir != dir) return@launch
entries = list
if (resetScroll) listState.scrollToItem(0)
}
@@ -252,7 +260,7 @@ fun FileManagerScreen(
fun performPaste() {
val sources = clipboardFiles
if (sources.isEmpty()) return
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
val dstDir = currentDir
val cut = isCutOperation
@@ -261,6 +269,7 @@ fun FileManagerScreen(
operationDeterminate = true
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
val job = coroutineContext[kotlinx.coroutines.Job]
var applyToAll: ConflictChoice? = null
var failed = 0
@@ -296,6 +305,7 @@ fun FileManagerScreen(
}
var lastPct = -1
val onProgress = FileOps.ProgressCallback { copied, total ->
job?.ensureActive()
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
if (pct != lastPct) {
lastPct = pct
@@ -304,8 +314,8 @@ fun FileManagerScreen(
}
val target = dst
val ok = withContext(Dispatchers.IO) {
if (cut) FileOps.moveWithProgress(src, target, onProgress)
else FileOps.copyWithProgress(src, target, onProgress)
if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false }
else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false }
}
if (ok) done++ else failed++
}
@@ -791,19 +801,21 @@ fun FileManagerScreen(
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
val allVisibleSelected = visibleEntries.isNotEmpty() && visibleEntries.all { it.absolutePath in selectedPaths }
val anyVisibleSelected = visibleEntries.any { it.absolutePath in selectedPaths }
val selBarPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp)
OutlinedButton(
onClick = {
selectedPaths = if (selectedPaths.size == entries.size) emptySet()
else entries.map { it.absolutePath }.toSet()
val shown = visibleEntries.map { it.absolutePath }
selectedPaths = if (allVisibleSelected) selectedPaths - shown.toSet() else selectedPaths + shown
},
contentPadding = selBarPadding,
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
) { Text(if (allVisibleSelected) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
enabled = anyVisibleSelected,
onClick = {
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
isCutOperation = false
selectionMode = false
selectedPaths = emptySet()
@@ -812,9 +824,9 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
enabled = anyVisibleSelected,
onClick = {
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
isCutOperation = true
selectionMode = false
selectedPaths = emptySet()
@@ -823,8 +835,8 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
enabled = anyVisibleSelected,
onClick = { pendingBulkDelete = visibleEntries.filter { it.absolutePath in selectedPaths } },
contentPadding = selBarPadding,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
@@ -842,7 +854,7 @@ fun FileManagerScreen(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
.clickable { performPaste() }
.clickable(enabled = !isOperationRunning) { performPaste() }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
@@ -878,6 +890,7 @@ fun FileManagerScreen(
)
if (operationJob != null) {
TextButton(onClick = {
cancelledJob = operationJob
operationJob?.cancel()
operationJob = null
isOperationRunning = false
@@ -964,8 +977,7 @@ fun FileManagerScreen(
.fillMaxSize()
.nestedScroll(pullState.nestedScrollConnection),
) {
val shownEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
val shownEntries = visibleEntries
if (showGrid && entries.isNotEmpty()) {
LazyVerticalGrid(
@@ -45,22 +45,25 @@ object FileOps {
return f.length()
}
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
if (sameFile(src, dst)) return true
if (isCancelled()) return false
if (!dst.exists() && src.renameTo(dst)) {
progress?.onProgress(1, 1)
return true
}
if (!copyWithProgress(src, dst, progress)) return false
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
if (isCancelled()) return false
return delete(src)
}
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
val total = maxOf(totalSize(src), 1L)
return copyWithProgress(src, dst, longArrayOf(0), total, progress)
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
}
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean {
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean {
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
if (isSymlink(src)) return true
if (sameFile(src, dst)) return true
if (src.isDirectory && isWithin(dst, src)) {
@@ -71,7 +74,7 @@ object FileOps {
if (!dst.exists() && !dst.mkdirs()) return false
var allOk = true
src.list()?.forEach { name ->
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) {
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
Log.e(TAG, "Failed to copy: ${File(src, name)}")
allOk = false
}
@@ -81,13 +84,41 @@ object FileOps {
val parent = dst.parentFile
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
val chunk = 4L * 1024 * 1024
var staging: File? = null
var created = false
var output: java.nio.channels.FileChannel? = null
try {
output = try {
java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW)
.also { created = true }
} catch (e: java.nio.file.FileAlreadyExistsException) {
null
}
val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst
var permissions: Set<java.nio.file.attribute.PosixFilePermission>? = null
if (output == null) {
if (isSymlink(target) || target.parentFile?.isDirectory != true) {
throw IOException("Destination symlink target is not a writable directory entry: $dst")
}
if (target.isDirectory) throw IOException("Destination is a directory: $target")
permissions = try {
Files.getPosixFilePermissions(target.toPath())
} catch (e: java.nio.file.NoSuchFileException) {
null
} catch (e: UnsupportedOperationException) {
null
}
if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target")
val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-")
staging = directory.toFile()
output = FileOutputStream(File(staging, "file")).channel
}
FileInputStream(src).channel.use { input ->
FileOutputStream(dst).channel.use { output ->
checkNotNull(output).use { channel ->
val size = input.size()
var position = 0L
while (position < size) {
val transferred = input.transferTo(position, minOf(chunk, size - position), output)
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
if (transferred <= 0) break
position += transferred
done[0] += transferred
@@ -96,11 +127,28 @@ object FileOps {
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
}
}
staging?.let { directory ->
val copied = File(directory, "file").toPath()
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
Files.setPosixFilePermissions(copied, permissions)
}
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
}
return true
} catch (e: java.util.concurrent.CancellationException) {
if (created) dst.delete()
throw e
} catch (e: IOException) {
Log.e(TAG, "Failed to copy file: $src to $dst", e)
dst.delete()
if (created) dst.delete()
return false
} finally {
output?.close()
staging?.let { directory ->
File(directory, "file").delete()
directory.delete()
}
}
}
@@ -41,10 +41,20 @@ object AddedGames {
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP = Regex(
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
private val SKIP_PREFIX = Regex("(?i)^(unins|setup|vc_?redist|dxsetup|dxwebsetup|dotnet|directx|easyanticheat|battleye).*")
private val SKIP_PART = Regex("(?i)redist|prereq|installer|uninstall|crashreport|crashhandler|crashpad|crashsender|launchhelper")
private val SKIP_WORDS = setOf(
"eac", "report", "reporter", "editor", "server", "benchmark", "helper",
"update", "updater", "config", "configure", "configurator", "configuration", "settings",
)
private val WORD = Regex("[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+")
internal fun skipExe(name: String): Boolean {
if (!name.endsWith(".exe", ignoreCase = true)) return false
val base = name.dropLast(4)
if (SKIP_PREFIX.matches(base) || SKIP_PART.containsMatchIn(base)) return true
return WORD.findAll(base).any { it.value.lowercase() in SKIP_WORDS }
}
const val GUEST_DIR = "/root/Games"
@@ -75,7 +85,7 @@ object AddedGames {
val key = "${folder.path}|${folder.lastModified()}"
val now = System.currentTimeMillis()
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
val guessed = GameExePicker.rank(folder) { !skipExe(it.name) }
val declared = declaredExes(folder)
val preferred = declared.map { it.path }.toSet()
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
@@ -128,7 +138,7 @@ object AddedGames {
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
return names.asSequence()
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) && it.isFile }
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
.distinctBy { it.path }
.toList()
@@ -204,8 +214,10 @@ object AddedGames {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
val entries = AddedExes.list(context)
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
@@ -220,6 +232,24 @@ object AddedGames {
return null
}
private fun currentExe(context: Context, entries: List<AddedExes.Entry>, folder: File): File? =
SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File)
?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) }
?: rank(folder).exes.firstOrNull()
private fun sameGame(folder: File, current: File?, pick: File): Boolean {
val base = canonical(folder)
if (current == null || pick.parentFile?.let(::canonical) == base) return true
val known = canonical(current)
if (!inside(known, base) || known.substringBeforeLast('/') == base) return true
val picked = canonical(pick)
fun top(p: String) = p.removePrefix("$base/").substringBefore('/')
fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() }
return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) }
}
private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$")
fun addExe(context: Context, exe: File): AddResult {
val app = context.applicationContext
if (!exe.isFile) return AddResult.NotFound
@@ -228,7 +258,10 @@ object AddedGames {
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
}
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
if (guestPath(app, exe) == null) {
(owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) }
if (guestPath(app, exe) == null) return AddResult.Unreachable
}
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
@@ -185,7 +185,7 @@ object GameIdentifier {
}
private val JUNK_NAME_RE = Regex(
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*|bootstrappackagedgame)$"""
)
fun isJunkPeName(name: String): Boolean {
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoresState
import java.io.File
object GameRemoval {
enum class Kind { STORE, SHORTCUT, LAUNCHER }
fun kind(game: Library.SteamGame): Kind = when {
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
else -> Kind.STORE
}
class SteamFiles(val dir: File, val manifest: File, val partial: File?)
fun steamFiles(game: Library.SteamGame): SteamFiles? {
val dir = game.gameFiles ?: return null
val common = dir.parentFile ?: return null
if (common.name != "common") return null
val steamapps = common.parentFile ?: return null
if (steamapps.name != "steamapps") return null
val manifest = File(steamapps, "appmanifest_${game.appId}.acf")
if (!manifest.isFile) return null
if (!declares(manifest, game.appId)) return null
return SteamFiles(dir, manifest, File(steamapps, "downloading/${game.appId}"))
}
fun delete(target: SteamFiles): Boolean {
StoreInstalls.deleteTree(target.dir)
if (target.dir.exists()) return false
target.partial?.let { StoreInstalls.deleteTree(it) }
target.manifest.delete()
return !target.manifest.exists()
}
fun storeGame(game: Library.SteamGame): InstalledStoreGame? {
val folder = game.gameFiles ?: return null
val store = Store.byId(game.source)
val id = game.storeId
if (store != null && id != null) StoresState.installedGame(store, id)?.let { return it }
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled } ?: return null
return InstalledStoreGame(sidecar, folder)
}
private fun declares(manifest: File, appId: Int): Boolean =
runCatching { manifest.readText() }.getOrNull()?.let { Regex("\"appid\"\\s+\"$appId\"").containsMatchIn(it) } == true
}
@@ -153,7 +153,7 @@ object SteamAppInfo {
return String(bytes, Charsets.UTF_8)
}
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
internal fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
@@ -1,8 +1,10 @@
package com.droiddeck.launcher.glasses
internal enum class HandModel(val asset: String, val sha256: String) {
FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
import com.droiddeck.launcher.handtracking.TrackingPack
internal enum class HandModel(val file: String, val sha256: String) {
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
companion object {
val DEFAULT = FULL
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.handtracking.TrackingPack
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
@@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
} }
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
val bytes=context.assets.open(selected.asset).use { it.readBytes() }
val bytes=TrackingPack.file(context,selected.file).readBytes()
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset)
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() })
.setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f)
.setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build())
@@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
}
private fun loop() {
try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return }
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
if(state is AddonPack.State.Downloading)
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
}
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
var grayL=Mat();var grayR=Mat();val rgba=Mat()
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
@@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
var rate=0f
try {
check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) }
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
joyConFlow=StereoJoyConFlow()
while(running) {
while(true) {
@@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executors
import kotlin.math.abs
@@ -188,9 +186,8 @@ internal class HandCamera(
private fun prepare() {
if (stopped) return
runCatching {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
val model = TrackingPack.model(context, TrackingPack.POSE)
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO)
@@ -305,7 +302,6 @@ internal class HandCamera(
companion object {
private const val TAG = "HandCamera"
private const val MODEL = "handtracking/pose_landmarker_lite.task"
private const val ANALYSIS_WIDTH = 960
private const val ANALYSIS_HEIGHT = 720
private const val MIN_CONFIDENCE = 0.5f
@@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.hypot
import kotlin.math.max
@@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
companion object {
private const val MODEL = "handtracking/hand_landmarker.task"
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
@@ -153,10 +150,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
private const val MIN_CONFIDENCE = 0.4f
fun open(context: Context): HandModel {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
val model = TrackingPack.model(context, TrackingPack.HAND)
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(mode)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -16,9 +17,10 @@ internal object HandTracking {
val side: String? = null,
val permitted: Boolean = false,
val sides: Set<String> = emptySet(),
val downloaded: Boolean = false,
) {
val usable: Boolean get() = sides.isNotEmpty()
val ready: Boolean get() = enabled && permitted && usable
val ready: Boolean get() = enabled && permitted && usable && downloaded
}
private val state = MutableStateFlow(Settings())
@@ -38,8 +40,11 @@ internal object HandTracking {
side = prefs.getString(SIDE, null),
permitted = permitted(context),
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
downloaded = TrackingPack.installed(context),
)
state.value = next
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
TrackingPack.ensure(context)
return next
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.handtracking
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.AddonPack
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val TAG = "TrackingPack"
private val pack = AddonPack.TRACKING
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
@Volatile private var nativeLoaded = false
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
fun file(context: Context, name: String): File = pack.file(context, name)
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
object GestureKeys {
val codes: Map<String, Int> = linkedMapOf(
"ESC" to 1,
"1" to 2,
"2" to 3,
"3" to 4,
"4" to 5,
"5" to 6,
"6" to 7,
"7" to 8,
"8" to 9,
"9" to 10,
"0" to 11,
"MINUS" to 12,
"EQUAL" to 13,
"BKSP" to 14,
"TAB" to 15,
"Q" to 16,
"W" to 17,
"E" to 18,
"R" to 19,
"T" to 20,
"Y" to 21,
"U" to 22,
"I" to 23,
"O" to 24,
"P" to 25,
"BRACKET_LEFT" to 26,
"BRACKET_RIGHT" to 27,
"ENTER" to 28,
"CTRL_L" to 29,
"A" to 30,
"S" to 31,
"D" to 32,
"F" to 33,
"G" to 34,
"H" to 35,
"J" to 36,
"K" to 37,
"L" to 38,
"SEMICOLON" to 39,
"APOSTROPHE" to 40,
"GRAVE" to 41,
"SHIFT_L" to 42,
"BACKSLASH" to 43,
"Z" to 44,
"X" to 45,
"C" to 46,
"V" to 47,
"B" to 48,
"N" to 49,
"M" to 50,
"COMMA" to 51,
"PERIOD" to 52,
"SLASH" to 53,
"SHIFT_R" to 54,
"KP_MULTIPLY" to 55,
"ALT_L" to 56,
"SPACE" to 57,
"CAPS_LOCK" to 58,
"F1" to 59,
"F2" to 60,
"F3" to 61,
"F4" to 62,
"F5" to 63,
"F6" to 64,
"F7" to 65,
"F8" to 66,
"F9" to 67,
"F10" to 68,
"NUM_LOCK" to 69,
"SCROLL_LOCK" to 70,
"KP_7" to 71,
"KP_8" to 72,
"KP_9" to 73,
"KP_SUBTRACT" to 74,
"KP_4" to 75,
"KP_5" to 76,
"KP_6" to 77,
"KP_ADD" to 78,
"KP_1" to 79,
"KP_2" to 80,
"KP_3" to 81,
"KP_0" to 82,
"KP_DEL" to 83,
"F11" to 87,
"F12" to 88,
"KP_ENTER" to 96,
"CTRL_R" to 97,
"KP_DIVIDE" to 98,
"PRTSCN" to 99,
"ALT_R" to 100,
"HOME" to 102,
"UP" to 103,
"PRIOR" to 104,
"LEFT" to 105,
"RIGHT" to 106,
"END" to 107,
"DOWN" to 108,
"NEXT" to 109,
"INSERT" to 110,
"DEL" to 111,
)
@JvmStatic fun code(name: String): Int? = codes[name]
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.input
import android.content.Context
/** Global defaults with optional overrides for the currently focused game. */
object GesturePrefs {
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game")
fun read(context: Context, game: String? = null): TouchGestureConfig {
val p = prefs(context)
val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null)
return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json)
}
fun save(context: Context, game: String?, config: TouchGestureConfig) {
prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply()
}
fun clear(context: Context, game: String?) {
prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply()
}
}
@@ -146,9 +146,15 @@ public final class PadBridge {
}
}
static boolean isSystemVirtualInputName(String name) {
if (name == null) return false;
String lower = name.trim().toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("uinput-") || lower.startsWith("uinput_");
}
public static boolean isFromController(InputDevice device) {
if (device == null) return false;
if (device.isVirtual()) return false;
if (device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
int sources = device.getSources();
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
@@ -165,7 +171,7 @@ public final class PadBridge {
public static boolean isControllerKeyEvent(KeyEvent event) {
InputDevice device = event.getDevice();
if (device == null || device.isVirtual()) return false;
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
@@ -190,6 +196,7 @@ public final class PadBridge {
public static boolean isControllerMotionEvent(MotionEvent event) {
InputDevice device = event.getDevice();
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& !isSystemVirtualInputName(device.getName())
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
@@ -319,16 +326,27 @@ public final class PadBridge {
boolean wired = wiredXboxLayout(event);
pad.rightX = rightX(event, wired);
pad.rightY = rightY(event, wired);
float lt = leftTrigger(event, wired);
float rt = rightTrigger(event, wired);
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.right = hatX > 0.5f;
pad.down = hatY > 0.5f;
pad.left = hatX < -0.5f;
int source = event.getSource();
float lt = 0f;
float rt = 0f;
if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) {
lt = leftTrigger(event, wired);
pad.leftTrigger = lt;
}
if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
rt = rightTrigger(event, wired);
pad.rightTrigger = rt;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) {
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
pad.right = hatX > 0.5f;
pad.left = hatX < -0.5f;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_Y)) {
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.down = hatY > 0.5f;
}
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
notePlayerInput();
@@ -337,6 +355,12 @@ public final class PadBridge {
return true;
}
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;
return false;
}
public interface TouchRoute {
boolean active();
@@ -16,10 +16,13 @@ object SteamTouchBindings {
const val DEFAULT_FOREGROUND = "#232323"
const val DEFAULT_BACKGROUND = "#E4E4E4"
fun cleanLabel(raw: String): String =
raw.replace(Regex("""["\\,\p{Cntrl}]"""), " ").replace(Regex("""\s+"""), " ").trim().let { it.take(it.offsetByCodePoints(0, minOf(32, it.codePointCount(0, it.length)))) }
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
fun compose(): String =
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, ${cleanLabel(label)}, "
else "$action, ${cleanLabel(label)}, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
val hasIcon get() = icon.isNotEmpty()
}
@@ -2,10 +2,14 @@ package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.SteamAppInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Locale
object SteamTouchConfig {
private const val TAG = "SteamTouch"
@@ -125,17 +129,37 @@ object SteamTouchConfig {
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
fun gameName(context: Context, appId: Int): String? = try {
fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try {
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
(kv.child("AppState") ?: kv).string("name")
} catch (_: Exception) {
null
}
fun steamKey(context: Context, appId: Int): String =
if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT)
?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId)
else steamId(appId)
private fun shortcutName(context: Context, appId: Int): String? {
val account = accountId(context) ?: return null
return try {
val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes()
val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null)
(root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()
?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId }
?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
}
fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context)
fun configDir(context: Context): File? {
@@ -158,10 +182,11 @@ object SteamTouchConfig {
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
val file = dir?.let { deviceConfig(context, it, appId) }
?: dir?.let { autosaveConfig(it, appId) }
val key = steamKey(context, appId)
val file = dir?.let { deviceConfig(context, it, key) }
?: dir?.let { autosaveConfig(it, key) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
?: dir?.let { configsetConfig(context, it, key) }
if (file == null) {
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
return Config(null, null, null, emptyMap())
@@ -193,7 +218,7 @@ object SteamTouchConfig {
}
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
if (touchIndex == null) null else {
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""")
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
}
@@ -201,22 +226,22 @@ object SteamTouchConfig {
null
}
private fun autosaveConfig(dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
private fun autosaveConfig(dir: File, key: String): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key)
if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null
} catch (_: Exception) {
null
}
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key)
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key)
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key)
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf")
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
@@ -333,6 +358,11 @@ object SteamTouchConfig {
}.sortedBy { it.type }
}
fun remapHeld(held: Map<Int, Int?>, next: List<Element>): Map<Int, Element> {
val byType = next.associateBy { it.type }
return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap()
}
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
@@ -359,7 +389,8 @@ object SteamTouchConfig {
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val key = steamKey(context, appId)
val target = File(dir, "$key/controller_mobile_touch.vdf")
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
val written = sets.map { (id, elements) ->
@@ -377,27 +408,27 @@ object SteamTouchConfig {
val tmp = File(target.path + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
dropDeviceEntry(dir, appId)
selectAutosave(dir, key)
dropDeviceEntry(dir, key)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
private fun dropDeviceEntry(dir: File, appId: Int) {
private fun dropDeviceEntry(dir: File, key: String) {
val file = File(dir, DEVICE_CONFIGSET)
if (!file.isFile) return
val text = file.readText()
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
if (!block.containsMatchIn(text)) return
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
}
private fun selectAutosave(dir: File, appId: Int) {
private fun selectAutosave(dir: File, key: String) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" }
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
}
@@ -154,7 +154,7 @@ class SteamTouchControls(
actionSet = state.actionSet
layers = state.layers
appId = app
rebuild()
rebuild(keepHeld = true)
}
}
@@ -175,7 +175,7 @@ class SteamTouchControls(
refreshState()
}
private fun rebuild() {
private fun rebuild(keepHeld: Boolean = false) {
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options()
if (nextOptions != options) { options = nextOptions; onOptions(options) }
@@ -183,7 +183,7 @@ class SteamTouchControls(
rebuildVisuals()
if (!editing) {
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
releaseAll()
if (keepHeld) keepHeldFingers() else releaseAll()
}
invalidate()
}
@@ -590,6 +590,13 @@ class SteamTouchControls(
invalidate()
}
private fun keepHeldFingers() {
val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live())
fingers.keys.retainAll(moved.keys)
fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) }
publish()
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
@@ -958,7 +965,7 @@ class SteamTouchControls(
BIG_PICTURE -> "Steam"
else -> gameNames[appId] ?: run {
val app = appId
gameNames[app] = context.getString(R.string.touch_app_id, app)
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
loader.execute {
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
handler.post { gameNames[app] = name; invalidate() }
@@ -84,11 +84,11 @@ class SteamTouchIconPicker(
updatePreview(preview)
showPanel(host, title, root, listOf(
context.getString(R.string.touch_no_icon) to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = "", foreground = "", background = ""))
},
context.getString(R.string.common_cancel) to {},
context.getString(R.string.common_use) to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = icon,
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
},
)) { loader.shutdown() }
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
import org.json.JSONObject
data class TouchGestureConfig(
val tapEnabled: Boolean = true,
val tapAction: String = ACTION_LEFT_CLICK,
val dragEnabled: Boolean = true,
val dragAction: String = PAN_LEFT_CLICK_DRAG,
val longPressEnabled: Boolean = false,
val longPressAction: String = ACTION_RIGHT_CLICK,
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val longPressDelay: Int = DEFAULT_DELAY_MS,
val doubleTapEnabled: Boolean = true,
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
val twoFingerDragEnabled: Boolean = true,
val twoFingerDragAction: String = PAN_ARROW_KEYS,
val pinchEnabled: Boolean = true,
val pinchAction: String = ZOOM_SCROLL_WHEEL,
val twoFingerTapEnabled: Boolean = true,
val twoFingerTapAction: String = ACTION_RIGHT_CLICK,
val twoFingerHoldEnabled: Boolean = true,
val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK,
val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val threeFingerTapEnabled: Boolean = true,
val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD,
val threeFingerDragEnabled: Boolean = false,
val threeFingerDragAction: String = PAN_ARROW_KEYS,
val threeFingerHoldEnabled: Boolean = true,
val threeFingerHoldAction: String = ACTION_KEY_ESC,
val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val showGestureDebugOverlay: Boolean = false,
val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD,
val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT,
) {
fun toJson(): String {
return JSONObject().apply {
put(KEY_TAP_ENABLED, tapEnabled)
put(KEY_TAP_ACTION, tapAction)
put(KEY_DRAG_ENABLED, dragEnabled)
put(KEY_DRAG_ACTION, dragAction)
put(KEY_LONG_PRESS_ENABLED, longPressEnabled)
put(KEY_LONG_PRESS_ACTION, longPressAction)
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
put(KEY_LONG_PRESS_DELAY, longPressDelay)
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction)
put(KEY_PINCH_ENABLED, pinchEnabled)
put(KEY_PINCH_ACTION, pinchAction)
put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled)
put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction)
put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled)
put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction)
put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior)
put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay)
put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled)
put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction)
put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled)
put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction)
put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled)
put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction)
put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior)
put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay)
put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay)
put(KEY_GESTURE_THRESHOLD, gestureThreshold)
put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode)
}.toString()
}
companion object {
const val DEFAULT_DELAY_MS = 300
const val DEFAULT_GESTURE_THRESHOLD = 40
const val ACTION_LEFT_CLICK = "left_click"
const val ACTION_RIGHT_CLICK = "right_click"
const val ACTION_MIDDLE_CLICK = "middle_click"
const val MOUSE_BEHAVIOR_HOLD = "hold"
const val MOUSE_BEHAVIOR_CLICK = "click"
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
const val ACTION_SHOW_KEYBOARD = "show_keyboard"
const val ACTION_KEY_ESC = "key_ESC"
const val ACTION_KEY_TILDE = "key_TILDE"
const val PAN_WASD = "wasd"
const val PAN_INVERTED_WASD = "inverted_wasd"
const val PAN_ARROW_KEYS = "arrow_keys"
const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys"
const val PAN_MIDDLE_MOUSE = "middle_mouse_pan"
const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan"
const val PAN_LEFT_CLICK_DRAG = "left_click_drag"
const val PAN_RIGHT_CLICK_DRAG = "right_click_drag"
const val ZOOM_SCROLL_WHEEL = "scroll_wheel"
const val ZOOM_PLUS_MINUS = "plus_minus"
const val ZOOM_PAGE_UP_DOWN = "page_up_down"
private const val KEY_TAP_ENABLED = "tapEnabled"
private const val KEY_TAP_ACTION = "tapAction"
private const val KEY_DRAG_ENABLED = "dragEnabled"
private const val KEY_DRAG_ACTION = "dragAction"
private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled"
private const val KEY_LONG_PRESS_ACTION = "longPressAction"
private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior"
private const val KEY_LONG_PRESS_DELAY = "longPressDelay"
private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled"
private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay"
private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled"
private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction"
private const val KEY_PINCH_ENABLED = "pinchEnabled"
private const val KEY_PINCH_ACTION = "pinchAction"
private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled"
private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction"
private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled"
private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction"
private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior"
private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay"
private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled"
private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction"
private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled"
private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction"
private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled"
private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction"
private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior"
private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay"
private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay"
private const val KEY_GESTURE_THRESHOLD = "gestureThreshold"
private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode"
private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig(
twoFingerHoldEnabled = false,
threeFingerTapEnabled = false,
threeFingerHoldEnabled = false,
)
fun fromJson(json: String?): TouchGestureConfig {
if (json.isNullOrBlank()) return fallbackDefaults()
return try {
val obj = JSONObject(json)
TouchGestureConfig(
tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true),
tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK),
dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true),
dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG),
longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false),
longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK),
longPressMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true),
doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS),
twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true),
twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true),
pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL),
twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true),
twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK),
twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false),
twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK),
twoFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false),
threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD),
threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false),
threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false),
threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC),
threeFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false),
gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD),
mouseDragMovementMode = normalizeMouseDragMovementMode(
obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT),
),
)
} catch (_: Exception) {
fallbackDefaults()
}
}
private fun normalizeMouseBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
private fun normalizeMouseDragMovementMode(mode: String?): String {
return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) {
MOUSE_DRAG_MOVEMENT_RELATIVE
} else {
MOUSE_DRAG_MOVEMENT_DIRECT
}
}
val PAN_ACTIONS = listOf(
PAN_ARROW_KEYS,
PAN_INVERTED_ARROW_KEYS,
PAN_WASD,
PAN_INVERTED_WASD,
PAN_MIDDLE_MOUSE,
PAN_INVERTED_MIDDLE_MOUSE,
PAN_LEFT_CLICK_DRAG,
PAN_RIGHT_CLICK_DRAG,
)
val ZOOM_ACTIONS = listOf(
ZOOM_SCROLL_WHEEL,
ZOOM_PLUS_MINUS,
ZOOM_PAGE_UP_DOWN,
)
@JvmStatic
fun isMouseButtonAction(action: String?): Boolean =
action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK
}
}
@@ -38,6 +38,7 @@ class TouchpadGestures(
private var moverLastX = 0f
private var moverLastY = 0f
private var holdDrag = false
private var scrollLocked = false
private var clicked = 0
private val longPress = Runnable {
held = true
@@ -67,6 +68,7 @@ class TouchpadGestures(
dragging = false
held = false
holdDrag = false
scrollLocked = false
anchorId = -1
moverId = -1
lastTapAt = 0L
@@ -84,7 +86,7 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
@@ -117,10 +119,12 @@ class TouchpadGestures(
val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId)
if (ai >= 0 && mi >= 0) {
val mx = event.getX(mi); val my = event.getY(mi)
if (!holdDrag && !scrolled) {
if (!holdDrag && !scrolled && !scrollLocked) {
val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY)
val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY)
if (moverMoved > slopPx && anchorMoved < slopPx) {
if (anchorMoved >= slopPx) {
scrollLocked = true
} else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) {
holdDrag = true
secondWasTap = false
if (!dragging) {
@@ -186,9 +190,9 @@ class TouchpadGestures(
if (gone == moverId) moverId = -1
if (gone == anchorId) {
anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0)
event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
}
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
@@ -212,7 +216,7 @@ class TouchpadGestures(
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; anchorId = -1; moverId = -1
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
return true
}
}
@@ -225,5 +229,7 @@ class TouchpadGestures(
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
}
File diff suppressed because it is too large. Load diff
@@ -47,6 +47,53 @@ object DesktopCatalog {
private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id")
private const val JOURNAL_SUFFIX = ".installing"
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
/** Packages whose extraction over the runtime started and never finished. */
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(root: File): List<Entry> =
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
.sortedBy { it.name }
.mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } }
private val installLock = Any()
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
/** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
}
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
for (entry in interrupted(context)) {
Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}")
install(context, entry, listener)?.let { return it }
}
null
}
internal fun entryJson(e: Entry): JSONObject = JSONObject()
.put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind)
.put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes)
.put("icon", e.icon).put("category", e.category)
internal fun entryFrom(o: JSONObject) = Entry(
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"),
o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
/** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
private fun downloaded(file: File, entry: Entry): Boolean =
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
@@ -82,22 +129,38 @@ object DesktopCatalog {
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, listener)
}
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
var keepDownload = false
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val report = object : LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
RuntimeInstallService.packageProgress(stage, percent)
listener?.onProgress(step, stage, percent)
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
}
try {
if (!downloaded(download, entry)) {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
}
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -112,23 +175,28 @@ object DesktopCatalog {
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
check(!journal.exists() || journal.delete()) { "Could not finish package installation" }
keepDownload = false
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
if (!keepDownload) download.delete()
release()
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.os.Process;
import android.system.ErrnoException;
@@ -64,14 +65,12 @@ public final class LinuxRuntime {
private static final String HOST_DIR = "opt/android-host";
public static File prootBinary(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so");
return new File(rootDir(context), HOST_DIR + "/proot");
}
public static File prootLoader(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so");
return new File(rootDir(context), HOST_DIR + "/loader");
}
@@ -187,10 +186,15 @@ public final class LinuxRuntime {
} catch (java.io.IOException e) {
}
}
File fileMax = new File(fakeProc, "file-max");
if (!fileMax.isFile()) {
String[][] seeded = {
{"file-max", "9223372036854775807\n"},
{"overcommit_memory", "0\n"},
};
for (String[] entry : seeded) {
File fake = new File(fakeProc, entry[0]);
if (fake.isFile()) continue;
try {
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
}
}
@@ -205,6 +209,7 @@ public final class LinuxRuntime {
{"overflowuid", "/proc/sys/kernel/overflowuid"},
{"overflowgid", "/proc/sys/kernel/overflowgid"},
{"file-max", "/proc/sys/fs/file-max"},
{"overcommit_memory", "/proc/sys/vm/overcommit_memory"},
};
for (String[] entry : procFiles) {
File fake = new File(fakeProc, entry[0]);
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.util.Log;
@@ -261,6 +262,8 @@ 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 (!AddonPack.PROOT.await(context)) throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) 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,
@@ -456,9 +459,12 @@ public final class LinuxRuntimeInstaller {
}
}
// Packages extract over the live runtime and their archives list a link before the file it
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -477,29 +483,35 @@ public final class LinuxRuntimeInstaller {
if (parent != null && !parent.isDirectory()) parent.mkdirs();
if (entry.isSymbolicLink()) {
file.delete();
FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath());
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
links.remove(file);
File part = partFile(file);
part.delete();
try {
Files.createLink(file.toPath(), target.toPath());
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
} else if (entry.isFile()) {
try (OutputStream out = new FileOutputStream(file)) {
links.remove(file);
File part = partFile(file);
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
}
if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false);
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
} else {
continue;
}
@@ -507,10 +519,32 @@ public final class LinuxRuntimeInstaller {
listener.onProgress(extracting, -1);
}
}
for (java.util.Map.Entry<File, String> link : links.entrySet()) {
File file = link.getKey();
File part = partFile(file);
try {
Files.deleteIfExists(part.toPath());
Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue()));
place(part, file);
} catch (IOException e) {
Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e);
part.delete();
}
}
return true;
} catch (Exception e) {
Log.e(TAG, "extract", e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
private static void place(File part, File file) throws IOException {
java.nio.file.Path path = file.toPath();
if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path);
Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.security.MessageDigest
@@ -11,7 +12,7 @@ object ProotFastPath {
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
LinuxRuntime.prootBinary(context).parentFile == AddonPack.PROOT.dir(context)
fun key(root: File, binds: List<String>): String? {
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
@@ -0,0 +1,34 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
object RuntimeHealth {
private const val MAX_HOPS = 16
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
/** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List<String> {
val lib = File(root, "usr/lib")
val names = lib.list() ?: return emptyList()
return names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
var hops = 0
while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) {
val target = Files.readSymbolicLink(path)
path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target)
}
!Files.isSymbolicLink(path) && Files.exists(path)
} catch (e: Exception) {
false
}
}
@@ -19,6 +19,7 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
@@ -44,7 +45,27 @@ class RuntimeInstallService : Service() {
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
private fun holdPackage(startId: Int): Int {
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
while (packageHolds.get() > 0) {
val now = "$packageStage:$packagePercent"
if (now != shown) {
shown = now
manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
}
Thread.sleep(500)
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "package-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int, title: String? = null): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
@@ -58,7 +79,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -72,6 +93,11 @@ class RuntimeInstallService : Service() {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
@Volatile private var packageTitle = ""
@Volatile private var packageStage = ""
@Volatile private var packagePercent = -1
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -91,6 +117,24 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
/** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
fun holdForPackage(context: Context, title: String): () -> Unit {
packageTitle = title
packageStage = title
packagePercent = -1
packageHolds.incrementAndGet()
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
val released = java.util.concurrent.atomic.AtomicBoolean()
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
}
fun packageProgress(stage: String, percent: Int) {
packageStage = stage
packagePercent = percent
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -71,6 +71,9 @@ object UserApps {
private fun menuEntry(context: Context, id: String) =
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
fun launches(program: String?): Boolean =
program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER
fun list(context: Context): List<App> {
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
App(
@@ -30,7 +30,7 @@ object BootMovie {
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
sealed class Import {
data class Done(val name: String, val audioDropped: Boolean) : Import()
data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import()
object TooLong : Import()
data class Unsupported(val codec: String?) : Import()
object Failed : Import()
@@ -39,6 +39,21 @@ object BootMovie {
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
private fun customFile(context: Context) = File(dir(context), "custom.webm")
/** A movie imported before movies were kept in Steam's own folder. */
internal fun legacyCustomFile(context: Context) = customFile(context)
/** Unpacks one of DroidDeck's own movies on demand; null for anything else. */
internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) }
/** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */
fun select(context: Context, id: String) {
SessionPrefs.setBootMovie(context, id)
SessionPrefs.setBootMovieApplyNext(context, true)
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
fun customName(context: Context): String? =
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
@@ -46,21 +61,127 @@ object BootMovie {
val chosen = SessionPrefs.bootMovie(context)
return when {
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen
chosen in ASSETS -> chosen
else -> DROIDDECK_30
}
}
/** The file the current choice plays from, before any volume or length change. */
fun currentFile(context: Context): File? = sourceOf(context, choice(context))
private fun sourceOf(context: Context, choice: String): File? = when {
choice == CUSTOM -> customFile(context)
isLibraryId(choice) -> BootMovieLibrary.file(context, choice)
else -> ASSETS[choice]?.let { extractAsset(context, it) }
}
private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX)
fun prepare(context: Context): File? {
val choice = choice(context)
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
if (SessionPrefs.bootMovieSound(context)) return source
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
if (remux(source, muted, audio = false)) return muted
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
return null
BootMovieLibrary.restore(context)
val source = sourceOf(context, choice(context)) ?: return null
return variant(context, source)
}
/** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */
private fun variant(context: Context, source: File): File? {
val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0
val seconds = SessionPrefs.bootMovieSeconds(context)
val folder = File(dir(context), "variants")
val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds)
if (plan.passthrough) {
prune(folder, null)
return source
}
folder.mkdirs()
val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE
val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm")
if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) {
prune(folder, out.name)
return out
}
val staged = File(folder, out.name + ".staged")
val ok = runCatching {
plan.steps.any { step ->
when (step) {
Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs)
Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs)
Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs)
Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null
Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs)
}
}
}.getOrDefault(false)
if (ok && staged.renameTo(out)) {
prune(folder, out.name)
return out
}
staged.delete()
Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)")
return if (plan.sourceOnFailure) source else null
}
internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN }
/**
* How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and
* whether it still plays as it is when every step fails.
*/
internal class Plan(val passthrough: Boolean, val steps: List<Step>, val sourceOnFailure: Boolean)
internal fun plan(volume: Int, seconds: Int): Plan = when {
volume == 100 && seconds == 0 -> Plan(true, emptyList(), true)
volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false)
// Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder.
volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true)
else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false)
}
/** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */
internal const val MAX_LONG_SIDE = 1920
internal const val MAX_SHORT_SIDE = 1080
internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE
private fun oversized(format: MediaFormat) =
format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) &&
oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT))
private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true
/** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */
internal fun oversizedPlan(volume: Int): Plan =
Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false)
private fun transcodeSilent(source: File, target: File, limitUs: Long) =
BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null
/** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */
private fun prune(folder: File, keep: String?) {
folder.listFiles()?.forEach { if (it.name != keep) it.delete() }
}
/**
* Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before,
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
*/
fun syncFromSteam(context: Context): Boolean {
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
SessionPrefs.setBootMovieSteamSeen(context, path)
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
val prefix = "/uioverrides/movies/"
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) }
listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path
}
if (BootMovieLibrary.file(context, id) == null) return false
val changed = SessionPrefs.bootMovie(context) != id
BootMovieReport.note(context, "Steam names $path, taken over as $id")
SessionPrefs.setBootMovie(context, id)
return changed
}
private fun extractAsset(context: Context, name: String): File? {
@@ -82,11 +203,15 @@ object BootMovie {
}
}
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
fun import(
context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {},
title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null,
cover: ByteArray? = null, use: Boolean = true,
): Import {
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
val video = format.getString(MediaFormat.KEY_MIME)
val audioPlayable = audio == null || audio in AUDIO_CODECS
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format)
val copy = playable && audioPlayable && isWebm(file)
val remux = !copy && playable && video in REMUX_CODECS
if (!copy && !remux) {
@@ -95,11 +220,12 @@ object BootMovie {
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
onStart()
}
val target = customFile(context)
val staged = File(dir(context), "custom.webm.staged")
val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension
val library = BootMovieLibrary.libraryDir(context)
val staged = File(library, ".import-${System.nanoTime()}.staged")
var audioDropped = remux && !audioPlayable
return try {
target.parentFile?.mkdirs()
library.mkdirs()
val ok = when {
copy -> {
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
@@ -108,11 +234,10 @@ object BootMovie {
remux -> remux(file, staged, audio = true)
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
}
if (!ok || !staged.renameTo(target)) return Import.Failed
File(target.parentFile, "custom-muted.webm").delete()
SessionPrefs.setBootMovieCustomName(context, file.name)
SessionPrefs.setBootMovie(context, CUSTOM)
Import.Done(file.name, audioDropped)
if (!ok || !staged.setReadable(true, false)) return Import.Failed
val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed
if (use) select(context, BootMovieLibrary.idOf(name))
Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name))
} catch (e: Exception) {
Log.w(TAG, "could not import ${file.name}", e)
Import.Failed
@@ -124,8 +249,9 @@ object BootMovie {
fun removeCustom(context: Context) {
customFile(context).delete()
File(dir(context), "custom-muted.webm").delete()
File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() }
SessionPrefs.setBootMovieCustomName(context, null)
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30)
}
private fun isWebm(file: File): Boolean {
@@ -156,7 +282,7 @@ object BootMovie {
}
}
private fun remux(source: File, target: File, audio: Boolean): Boolean {
private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean {
val staged = File(target.parentFile, target.name + ".remux")
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
@@ -183,7 +309,7 @@ object BootMovie {
val info = MediaCodec.BufferInfo()
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0) break
if (size < 0 || extractor.sampleTime > limitUs) break
val track = tracks[extractor.sampleTrackIndex]
if (track != null) {
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
@@ -0,0 +1,410 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.media.MediaMetadataRetriever
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
/**
* The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in
* Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own
* three startup movies and the movies bought in Steam's points shop (neither can be removed).
*/
object BootMovieLibrary {
private const val TAG = "BootMovieLibrary"
const val PREFIX = "file:"
/** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:<path Steam names>`. */
const val STEAM_PREFIX = "steam:"
/** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */
private val lock = Any()
/** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */
internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm")
const val SOURCE_DROIDDECK = "droiddeck"
const val SOURCE_IMPORT = "import"
const val SOURCE_REPO = "repo"
const val SOURCE_STEAM = "steam"
const val SOURCE_POINTSHOP = "pointshop"
class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean)
internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies")
/**
* The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the
* x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file.
*/
internal fun customMovieDirs(context: Context): List<File> =
listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path }
/** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */
internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library")
private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json")
internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
/** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */
internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf")
private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs")
fun idOf(name: String) = PREFIX + name
/** Every Steam client folder: the usual one and the x86-64 client's. */
internal fun steamRoots(context: Context): List<File> = listOf(
steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
).filter { it.isDirectory }
private fun steamFile(context: Context, id: String): File? {
val path = id.removePrefix(STEAM_PREFIX)
if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null
return steamRoots(context).firstNotNullOfOrNull { root ->
listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L }
}
}
fun file(context: Context, id: String): File? {
if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id)
if (!id.startsWith(PREFIX)) return null
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
}
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
file.name !in STAGED && !file.name.startsWith(".")
private fun readMeta(context: Context): JSONObject =
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
/** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */
private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) {
val meta = readMeta(context)
if (change(meta)) {
val file = metaFile(context)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, file.name + ".staged")
runCatching {
staged.writeText(meta.toString())
if (!staged.renameTo(file)) staged.delete()
}.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) }
}
}
/** The post a movie was installed from, if it came from SteamDeckRepo. */
fun repoId(context: Context, name: String): String? =
readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() }
fun installedRepoIds(context: Context): Set<String> {
val meta = readMeta(context)
return meta.keys().asSequence().filter { file(context, idOf(it)) != null }
.mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet()
}
fun nameForRepo(context: Context, repoId: String): String? {
val meta = readMeta(context)
return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId }
}
fun list(context: Context): List<Entry> {
val out = ArrayList<Entry>()
out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false))
out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false))
BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) }
restore(context)
val meta = readMeta(context)
val names = HashSet<String>()
customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() }
.filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f ->
val info = meta.optJSONObject(f.name)
val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM
val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension
// Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand).
out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true))
}
val steam = LinkedHashMap<String, File>()
SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path ->
steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it }
}
val bought = HashMap<String, String>()
for (root in steamRoots(context)) {
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
val scan = pointShopMovies(root)
bought.putAll(scan.titles)
for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found)
}
val seen = HashSet<String>()
for ((id, file) in steam) {
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
val native = file.nameWithoutExtension.lowercase() in NATIVE
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
}
return out
}
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
private val scanLock = Any()
private class Scan(val at: Long, val files: List<File>, val titles: Map<String, String>)
private val scans = HashMap<String, Scan>()
/** Forgets the last search for Steam's own movies. */
internal fun forgetScan() = synchronized(scanLock) { scans.clear() }
/**
* Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the
* client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder.
*/
private fun pointShopMovies(root: File): Scan = synchronized(scanLock) {
val now = System.currentTimeMillis()
scans[root.path]?.let { if (now - it.at < 15_000L) return it }
val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top ->
val base = File(root, top)
if (!base.isDirectory) return@flatMap emptyList()
base.walkTopDown().maxDepth(5)
.onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") }
.take(20_000).filter { isMovie(it) }.toList()
}.sortedBy { it.path.lowercase() }
val titles = HashMap<String, String>()
for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } }
val scan = Scan(now, found, titles)
scans[root.path] = scan
scan
}
/** The item number in the name Steam gives a bought movie, `<item number>_<hash>.webm`; null for any other name. */
private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1)
/** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */
private fun boughtTitle(root: File, id: String): String? {
val key = "communityitemid\\\":\\\"$id\\\""
val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)")
for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) {
val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue
val at = text.indexOf(key)
if (at < 0) continue
title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private const val OVERRIDES = "/uioverrides/movies/"
/** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */
internal fun restore(context: Context) = synchronized(lock) {
migrateLegacyMeta(context)
val meta = readMeta(context)
val library = libraryDir(context)
meta.keys().asSequence().toList().forEach { name ->
val kept = File(library, name)
if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach
val copy = File(moviesDir(context), name)
if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy)
}
}
/** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */
private fun migrateLegacyMeta(context: Context) {
val legacy = File(moviesDir(context), ".droiddeck-movies.json")
if (!legacy.isFile) return
val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return
editMeta(context) { meta ->
var changed = false
old.keys().asSequence().toList().forEach { name ->
val info = old.optJSONObject(name) ?: return@forEach
if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true }
}
changed
}
legacy.delete()
}
/** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */
private fun mirror(kept: File, copy: File): Boolean {
val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged")
return try {
copy.parentFile?.mkdirs()
kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } }
staged.setReadable(true, false) && staged.renameTo(copy)
} catch (e: Exception) {
Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e)
false
} finally {
staged.delete()
}
}
fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id
/** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */
fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id)
/** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */
fun freeName(context: Context, title: String): String {
val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" }
val dirs = customMovieDirs(context)
val library = libraryDir(context)
var name = "$base.webm"
var n = 2
while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ }
return name
}
/**
* Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its
* title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time.
*/
internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? =
synchronized(lock) {
val name = freeName(context, title)
val kept = File(libraryDir(context), name)
if (!staged.renameTo(kept)) return null
if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null }
register(context, name, title, source, repoId, cover)
name
}
/** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */
fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) {
editMeta(context) { meta ->
val info = JSONObject().put("title", title).put("source", source)
if (repoId != null) info.put("repo", repoId)
meta.put(name, info)
true
}
if (cover != null) runCatching {
thumbsDir(context).mkdirs()
File(thumbsDir(context), coverName(name)).writeBytes(cover)
}
}
fun remove(context: Context, id: String): Boolean {
val removed = when {
id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true }
else -> {
// Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone.
if (!id.startsWith(PREFIX)) return false
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false
val copies = customMovieDirs(context).map { File(it, name) }
// A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted.
val target = copies.firstOrNull { it.exists() }
val managed = File(libraryDir(context), name)
if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false
synchronized(lock) {
val main = File(moviesDir(context), name)
val another = copies.any { it != target && it.exists() }
val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile)
var gone = target == null || target.delete()
if (gone && ownsRecord) {
gone = !managed.exists() || managed.delete()
if (gone) {
editMeta(context) { it.remove(name) != null }
File(thumbsDir(context), coverName(name)).delete()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
}
}
if (gone) {
val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) }
File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() }
}
gone
}
}
}
if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30)
return removed
}
/** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */
internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-"
private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun coverName(name: String) = "cover-" + safe(name)
/** How long a movie runs, in whole seconds; 0 when it cannot be read. */
fun durationSeconds(file: File?): Int {
if (file == null || !file.isFile) return 0
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
((ms + 500) / 1000).toInt()
} catch (e: Exception) {
0
} finally {
runCatching { retriever.release() }
}
}
/** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */
private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? {
var best: Bitmap? = null
var bestLuma = -1
for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) {
val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue
val luma = averageLuma(frame)
if (luma >= MIN_COVER_LUMA) return frame
if (luma > bestLuma) { best = frame; bestLuma = luma }
}
return best ?: runCatching { retriever.frameAtTime }.getOrNull()
}
private fun averageLuma(bitmap: Bitmap): Int {
val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true)
var sum = 0L
for (y in 0 until small.height) for (x in 0 until small.width) {
val c = small.getPixel(x, y)
sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10
}
return (sum / (small.width * small.height)).toInt()
}
private const val MIN_COVER_LUMA = 24
/** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */
fun cover(context: Context, entry: Entry): File? {
val file = fileOf(context, entry) ?: return null
val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id
// Points Shop covers once kept a picture that was not always the movie's own; those files are not used.
val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name))
if (given.isFile && given.length() > 0L) return given
val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg"
val frame = File(thumbsDir(context), key)
if (frame.isFile && frame.length() > 0L) return frame
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
val bitmap = brightestFrame(retriever, ms) ?: return null
val width = 480
val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap
thumbsDir(context).mkdirs()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
val staged = File(thumbsDir(context), "$key.staged")
staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) }
if (staged.renameTo(frame)) frame else null
} catch (e: Exception) {
Log.w(TAG, "no cover for ${file.name}", e)
null
} finally {
runCatching { retriever.release() }
}
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and
* where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config.
*/
object BootMovieReport {
private const val TAG = "BootMovieReport"
private const val MAX_TRACE_LINES = 200
private const val MAX_HITS = 60
private val KNOWN_TITLES = listOf("Turret Factory")
private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs")
private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak")
private val lock = Any()
private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log")
/** Adds a line to the short trace kept for the report; only the latest lines are kept. */
fun note(context: Context, line: String) {
synchronized(lock) {
runCatching {
val file = traceFile(context)
file.parentFile?.mkdirs()
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line"
file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n")
}
}
}
/** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */
fun write(context: Context): File? {
val text = runCatching { build(context) }.getOrElse {
Log.w(TAG, "could not build the report", it)
return null
}
val folders = listOfNotNull(
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"),
context.getExternalFilesDir(null),
)
for (folder in folders) {
val file = File(folder, "boot-movie-report.txt")
val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false)
if (saved) return file
Log.w(TAG, "could not write the report to ${file.path}")
}
return null
}
private fun describe(file: File) =
"${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}"
internal fun build(context: Context): String {
val out = StringBuilder()
fun line(text: String = "") { out.append(text).append('\n') }
fun listing(title: String, folder: File) {
line("-- $title: ${folder.path}")
val files = folder.listFiles()?.sortedBy { it.name.lowercase() }
if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)")
else files.forEach { line(" " + describe(it)) }
}
line("DroidDeck boot movie report, ${Date()}")
line()
line("== DroidDeck state")
line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}")
line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}")
line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}")
line("session running=${SessionState.running}")
line()
line("== The movie list as shown")
BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") }
line()
line("== DroidDeck folders")
listing("library", BootMovieLibrary.libraryDir(context))
listing("processed copies", File(context.filesDir, "boot-movie/variants"))
val meta = File(context.filesDir, "boot-movie/movies.json")
line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}")
val roots = BootMovieLibrary.steamRoots(context)
val movieIds = HashSet<String>()
for (root in roots) {
line()
line("== Steam folder ${root.path}")
val config = File(root, "config/config.vdf")
line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}")
val text = runCatching { config.readText() }.getOrNull()
if (text != null) {
line("-- StartupMovie block:")
(SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- Customization section with the lines before it:")
(SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- every line of config.vdf that mentions a movie:")
text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") }
Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) }
}
listing("custom movies", File(root, "config/uioverrides/movies"))
listing("Steam's own movies", File(root, "steamui/movies"))
listing("config/movies", File(root, "config/movies"))
line("-- every .webm below config, userdata, appcache and steamui:")
var shown = 0
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue
if (shown++ >= 300) break
line(" " + describe(file))
Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) }
}
}
}
line()
line("== Where Steam keeps movie names (text found in Steam's files)")
val needles = (KNOWN_TITLES + movieIds).distinct()
line("looking for: ${needles.joinToString(", ")}")
search(roots, needles) { line(" $it") }
line()
line("== What each session start did to Steam's config (latest last)")
val trace = traceFile(context)
if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)")
return out.toString()
}
private fun search(roots: List<File>, needles: List<String>, report: (String) -> Unit) {
if (needles.isEmpty()) return
val deadline = System.currentTimeMillis() + 25_000L
var scanned = 0
var hits = 0
outer@ for (root in roots) {
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue
if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue
for (needle in needles) {
val at = text.indexOf(needle)
if (at < 0) continue
hits++
val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length))
report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString(""))
}
}
}
}
report("(searched $scanned files, $hits matches)")
}
}
@@ -37,15 +37,77 @@ internal object BootMovieTranscoder {
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
}.getOrDefault(false)
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? {
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
val audio = if (hadAudio) runCatching { encodeAudio(source) }
val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) }
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
return Result(audioDropped = hadAudio && !muxedAudio)
}
/** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */
fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean {
val audio = runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
val staged = File(target.parentFile, target.name + ".regain")
return try {
extractor.setDataSource(source.path)
var videoTrack = -1
var videoFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) {
videoTrack = i
videoFormat = format
}
}
if (videoTrack < 0 || videoFormat == null) return false
extractor.selectTrack(videoTrack)
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
val outVideo = mux.addTrack(videoFormat)
val outAudio = mux.addTrack(audio.format)
mux.start()
val info = MediaCodec.BufferInfo()
var audioIndex = 0
fun writeAudioUpTo(timeUs: Long) {
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
val sample = audio.samples[audioIndex++]
info.set(0, sample.data.size, sample.timeUs, 0)
mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info)
}
}
var bufferSize = 8 shl 20
if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
val buffer = ByteBuffer.allocate(bufferSize)
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0 || extractor.sampleTime > limitUs) break
val time = extractor.sampleTime
writeAudioUpTo(time)
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
mux.writeSampleData(outVideo, buffer, info)
extractor.advance()
}
writeAudioUpTo(Long.MAX_VALUE)
mux.stop()
mux.release()
muxer = null
staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not change the volume of ${source.name}", e)
false
} finally {
runCatching { muxer?.release() }
extractor.release()
staged.delete()
}
}
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
val extractor = MediaExtractor()
return try {
@@ -75,6 +137,14 @@ internal object BootMovieTranscoder {
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
}
/** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */
internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray {
val frames = pcm.size / channels
val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt()
if (gain == 1f && kept == frames) return pcm
return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() }
}
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
if (rate == OPUS_RATE) return pcm
val frames = pcm.size / channels
@@ -94,7 +164,10 @@ internal object BootMovieTranscoder {
return out
}
private fun encodeAudio(source: File): Audio? {
/** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */
private const val MAX_PCM_BYTES = 64 shl 20
private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? {
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
@@ -139,13 +212,16 @@ internal object BootMovieTranscoder {
}
dec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
// Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped.
if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break
if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break }
}
}
if (channels !in 1..2) return null
val raw = pcm.toByteArray()
val shorts = ShortArray(raw.size / 2)
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
val samples = resample(shorts, channels, rate)
val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs)
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
@@ -201,7 +277,7 @@ internal object BootMovieTranscoder {
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean {
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
@@ -278,7 +354,8 @@ internal object BootMovieTranscoder {
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
if (index >= 0) {
val timeUs = decInfo.presentationTimeUs
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
val pastLimit = timeUs > limitUs
val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
dec.releaseOutputBuffer(index, keep)
if (keep) {
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
@@ -290,9 +367,10 @@ internal object BootMovieTranscoder {
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
}
}
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
enc.signalEndOfInputStream()
decoderDone = true
inputDone = true
}
}
}
@@ -0,0 +1,179 @@
package com.droiddeck.launcher.session
import android.os.FileObserver
import android.os.SystemClock
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.FakeInputWriter
import java.io.File
class InputSysfsComponent(
private val nodes: File,
base: File,
private val udevData: File,
private val deckPad: Boolean,
private val steamVirtual: Boolean,
) : SessionPart() {
private val devices = File(base, "devices")
private val classDir = File(base, "class")
private val shown = HashSet<Int>()
private var observer: FileObserver? = null
private var running = false
fun prepare(): Boolean = try {
FileUtils.clear(devices)
FileUtils.clear(classDir)
devices.mkdirs()
classDir.mkdirs()
udevData.mkdirs()
udevData.listFiles()?.forEach { if (isPadRecord(it.name)) it.delete() }
devices.isDirectory && classDir.isDirectory
} catch (e: Exception) {
Log.w(TAG, "pads: could not prepare the input sysfs: $e")
false
}
fun binds() = listOf(devices.path + ":" + GUEST_DEVICES, classDir.path + ":" + GUEST_CLASS)
override fun start() {
synchronized(this) { running = true }
active = this
@Suppress("DEPRECATION")
observer = object : FileObserver(nodes.path, CREATE or DELETE or MOVED_TO or MOVED_FROM or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
if (path != null && NODE_FILE.matches(path)) sync()
}
}.also { it.startWatching() }
sync()
}
override fun stop() {
observer?.stopWatching()
observer = null
if (active === this) active = null
synchronized(this) {
running = false
for (node in shown.toList()) hide(node)
}
}
@Synchronized
fun sync() {
if (!running) return
val wanted = (0 until NODE_LIMIT).filter { node ->
File(nodes, "event$node").exists() && if (node >= FIRST_VIRTUAL) File(nodes, ".uinput-event$node.uevent").isFile
else !ringWithdrawn(deckPad, padType)
}.toSet()
for (node in shown - wanted) hide(node)
for (node in wanted - shown) show(node)
}
private fun show(node: Int) {
val pad = (if (node >= FIRST_VIRTUAL) runCatching { File(nodes, ".uinput-event$node.uevent").readText() }.getOrNull()?.let(::virtualPad)
else ringPad(node, steamVirtual)) ?: return
try {
val input = File(devices, "input$node")
val event = File(input, "event$node")
event.mkdirs()
File(input, "uevent").writeText(pad.uevent())
File(input, "name").writeText(pad.name + "\n")
File(input, "phys").writeText(pad.phys + "\n")
File(input, "uniq").writeText(pad.uniq + "\n")
File(input, "properties").writeText("0\n")
File(input, "id").mkdirs()
File(input, "id/bustype").writeText("%04x\n".format(pad.bus))
File(input, "id/vendor").writeText("%04x\n".format(pad.vendor))
File(input, "id/product").writeText("%04x\n".format(pad.product))
File(input, "id/version").writeText("%04x\n".format(pad.version))
File(input, "capabilities").mkdirs()
for (name in CAPABILITIES) File(input, "capabilities/$name").writeText("0\n")
link(File(input, "subsystem"), GUEST_CLASS)
File(event, "uevent").writeText("MAJOR=$MAJOR\nMINOR=${MINOR_BASE + node}\nDEVNAME=input/event$node\n")
File(event, "dev").writeText("$MAJOR:${MINOR_BASE + node}\n")
link(File(event, "subsystem"), GUEST_CLASS)
link(File(event, "device"), "$GUEST_DEVICES/input$node")
val record = udevRecord(SystemClock.elapsedRealtimeNanos() / 1000)
File(udevData, "+input:input$node").writeText(record)
File(udevData, "c$MAJOR:${MINOR_BASE + node}").writeText(record)
link(File(classDir, "input$node"), "$GUEST_DEVICES/input$node")
link(File(classDir, "event$node"), "$GUEST_DEVICES/input$node/event$node")
shown.add(node)
Log.i(TAG, "pads: /dev/input/event$node listed for udev as \"${pad.name}\" (%04x:%04x)".format(pad.vendor, pad.product))
} catch (e: Exception) {
Log.w(TAG, "pads: could not list /dev/input/event$node for udev: $e")
hide(node)
}
}
private fun hide(node: Int) {
File(classDir, "event$node").delete()
File(classDir, "input$node").delete()
File(udevData, "c$MAJOR:${MINOR_BASE + node}").delete()
File(udevData, "+input:input$node").delete()
File(devices, "input$node").let { FileUtils.clear(it); it.delete() }
if (shown.remove(node)) Log.i(TAG, "pads: /dev/input/event$node no longer listed for udev")
}
private fun link(file: File, target: String) {
file.delete()
Os.symlink(target, file.path)
}
data class Pad(
val bus: Int,
val vendor: Int,
val product: Int,
val version: Int,
val name: String,
val phys: String,
val uniq: String,
) {
fun uevent(): String =
"PRODUCT=%x/%x/%x/%x\nNAME=\"$name\"\nPHYS=\"$phys\"\nUNIQ=\"$uniq\"\nPROP=0\n".format(bus, vendor, product, version)
}
companion object {
private const val TAG = "SessionService"
const val GUEST_DEVICES = "/sys/devices/virtual/input"
const val GUEST_CLASS = "/sys/class/input"
private const val MAJOR = 13
private const val MINOR_BASE = 64
private const val FIRST_VIRTUAL = 16
private const val NODE_LIMIT = 64
private val NODE_FILE = Regex("(\\.uinput-)?event\\d+(\\.uevent)?")
private val CAPABILITIES = listOf("ev", "key", "rel", "abs", "msc", "sw", "led", "snd", "ff")
@Volatile private var active: InputSysfsComponent? = null
@Volatile private var padType = -1
fun padTypeChanged(type: Int) {
padType = type
active?.let { Thread(it::sync, "input-sysfs").start() }
}
internal fun ringWithdrawn(deckPad: Boolean, type: Int) = deckPad && type != FakeInputWriter.PAD_TYPE_XBOX
internal fun isPadRecord(name: String) =
Regex("c$MAJOR:(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it in MINOR_BASE until MINOR_BASE + NODE_LIMIT } == true ||
Regex("\\+input:input(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it < NODE_LIMIT } == true
internal fun udevRecord(initializedUs: Long) =
"I:$initializedUs\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\nE:ID_SERIAL=noserial\nG:seat\nG:uaccess\nQ:seat\nQ:uaccess\nV:1\n"
internal fun ringPad(node: Int, steamVirtual: Boolean) = if (steamVirtual) {
Pad(3, 0x28de, 0x11ff, 0x110, "Microsoft X-Box 360 pad $node", "usb-fakeinput/input$node", "0000000000%02d".format(node))
} else {
Pad(3, 0x045e, 0x028e, 0x110, "Xbox 360 Controller ($node)", "usb-fakeinput/input$node", "0000000000%02d".format(node))
}
internal fun virtualPad(uevent: String): Pad? {
val values = uevent.lines().mapNotNull { line ->
line.indexOf('=').takeIf { it > 0 }?.let { line.substring(0, it) to line.substring(it + 1).trim('"') }
}.toMap()
val id = values["PRODUCT"]?.split('/')?.mapNotNull { it.toIntOrNull(16) }?.takeIf { it.size == 4 } ?: return null
return Pad(id[0], id[1], id[2], id[3], values["NAME"] ?: return null, values["PHYS"].orEmpty(), values["UNIQ"].orEmpty())
}
}
}
@@ -118,7 +118,9 @@ object SessionArtifacts {
private fun scrubFolder(dir: File, record: Record) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
if (LogRedactor.isBinary(f)) return@forEach
// Too large to rewrite in memory: the folder must not be recorded as scrubbed.
if (!LogRedactor.isText(f)) { record.failed = true; return@forEach }
val done = scrubFile(dir, f)
if (done == null) record.failed = true else record.files[f] = done
}
@@ -1,10 +1,12 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LegacyNames
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -15,6 +17,7 @@ object SessionFiles {
private const val TAG = "SessionFiles"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
private const val STAGED_LEDGER = "usr/local/.droiddeck-staged"
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
internal val DESKTOP_FILES = arrayOf(
@@ -27,7 +30,8 @@ object SessionFiles {
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
fun stage(context: Context, root: File) {
/** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
fun stage(context: Context, root: File, startsSession: Boolean = false) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
@@ -134,15 +138,15 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + listOf("xodus-cli", "xodus-service", "LICENSE.xodus", "LICENSE.nexus", "LICENSE.LGPL-2.1", "LICENSE.nghttp2", "backend.lock.json", "runtime.lock.json", "version",
"windows/xgameruntime.dll", "windows/winhttp.dll", "windows/windows.web.dll", "windows/twinapi.appcore.dll", "windows/libnghttp2.dll")
.map { "usr/local/lib/droiddeck-xbox/$it" to "usr/local/lib/droiddeck-xbox/$it" } + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context))
for ((asset, relative) in all) {
val target = File(root, relative)
if (ledger.current(relative, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
@@ -156,7 +160,7 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "$relative NOT staged")
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
}
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
LegacyNames.migrateRootfs(root)
@@ -168,13 +172,17 @@ object SessionFiles {
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val directAudioPack = AddonPack.DIRECTAUDIO.takeIf { it.installed(context) }?.dir(context)
if (directAudioPack == null) Log.w(TAG, "DirectAudio pack is not installed; drivers not staged")
if (directAudioPack != null) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative"
val target = File(root, key)
if (ledger.current(key, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/$set/$relative").use { input ->
File(directAudioPack, "$set/$relative").inputStream().use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
@@ -183,8 +191,9 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
ledger.save()
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
@@ -201,20 +210,118 @@ object SessionFiles {
if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) {
Log.e(TAG, "could not write steam-compatibility-label")
}
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
|| !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) {
stagedSocket.delete()
Log.e(TAG, "could not write bwrap-socket")
}
BootMovie.syncFromSteam(context)
val startupMovie = BootMovie.prepare(context)
// The x86-64 client's config is left alone until its first start creates it.
val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam"))
.filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory }
for (steam in steamRoots) {
val startupMovieDir = File(steam, "config/uioverrides/movies")
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(steam, "config/config.vdf"))
val force = SessionPrefs.bootMovieApplyNext(context)
val steamHomes = listOf(File(root, "root/.local/share/Steam")) +
listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory }
var allDone = true
var allInstalled = true
var seen: String? = null
val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64"
for (home in steamHomes) {
val movieDir = File(home, "config/uioverrides/movies")
val config = File(home, "config/config.vdf")
if (startupMovie == null) {
// Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either.
for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) {
File(movieDir, name).delete()
File(movieDir, ".$name.source").delete()
}
}
val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm")
val before = startupMoviePath(config)
val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force)
BootMovieReport.note(
context,
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
)
if (!done) allDone = false
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
}
// A pick made in the app is done once a session started with every Steam client's config having it; guest commands run
// before that, and a Steam that is already running writes its own choice back when it exits.
if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false)
seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
/**
* Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that
* could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config
* no longer names a missing movie.
*/
internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean {
if (!config.isFile) return true
val text = runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config to reset the startup movie", it)
return false
}
val movie = activeStartupMovie(text) ?: return true
val block = text.substring(movie.keyStart, movie.close + 1)
val path = scalar(block, "LocalPath").orEmpty()
if (path.isEmpty()) return true
val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR))
val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH)
if (!gone) return false
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline)
val staged = File(config.parentFile, config.name + ".staged")
return try {
staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated))
(staged.renameTo(config)).also { if (!it) staged.delete() }
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not reset Steam startup movie selection", e)
false
}
}
private fun apkIdentity(context: Context): String {
val apk = File(context.applicationInfo.sourceDir)
return "${apk.path} ${apk.length()} ${apk.lastModified()}"
}
private class StageLedger(private val file: File, private val identity: String) {
private val before: Map<String, String> = FileUtils.readString(file)?.lines()
?.takeIf { it.firstOrNull() == identity }
?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap()
.orEmpty()
private val after = LinkedHashMap<String, String>()
fun current(relative: String, target: File): Boolean {
val mark = mark(target) ?: return false
if (before[relative] != mark) return false
after[relative] = mark
return true
}
fun staged(relative: String, target: File) {
mark(target)?.let { after[relative] = it }
}
fun save() {
if (after == before) return
val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n")
val staged = File(file.parentFile, file.name + ".staged")
if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) {
staged.delete()
file.delete()
}
}
private fun mark(target: File): String? =
if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null
}
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
@@ -230,7 +337,11 @@ object SessionFiles {
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
if (installed) FileUtils.writeString(stamp, identity)
if (installed) {
FileUtils.writeString(stamp, identity)
// Steam's picker draws its preview from this picture and does not redraw it when the movie changes.
File(directory, name.removeSuffix(".webm") + ".jpg").delete()
}
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
@@ -242,43 +353,108 @@ object SessionFiles {
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
private fun ensureStartupMovieDefault(config: File) {
val path = "/uioverrides/movies/bigpicture_startup.webm"
internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/"
internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm"
/**
* Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam
* settings is where older clients kept it, and DroidDeck still keeps it in step.
*/
private fun customizationMovie(text: String): VdfBlock? {
var from = 0
while (true) {
val customization = findObject(text, "Customization", from, text.length) ?: return null
findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it }
from = customization.close + 1
}
}
/** The older `StartupMovie` section: the first one that is not inside `Customization`. */
private fun olderMovie(text: String): VdfBlock? {
val inside = customizationMovie(text)
var from = 0
while (true) {
val movie = findObject(text, "StartupMovie", from, text.length) ?: return null
if (inside == null || movie.keyStart != inside.keyStart) return movie
from = movie.close + 1
}
}
/** The section Steam reads: the one under `Customization` when there is one. */
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
internal fun startupMoviePath(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val movie = activeStartupMovie(text) ?: return null
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
}
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
internal fun startupMovieBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val parts = listOfNotNull(
customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) },
olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) },
)
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
/** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */
internal fun customizationBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val customization = findObject(text, "Customization", 0, text.length) ?: return null
val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n")
return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000)
}
private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String {
val block = text.substring(movie.keyStart, movie.close + 1)
return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline))
}
/** True when Steam's config selects DroidDeck's staged movie once this returns. */
internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean {
val path = BOOT_MOVIE_PATH
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config for startup movie", it)
return
return false
} else ""
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val movie = findObject(text, "StartupMovie", 0, text.length)
if (movie != null) {
val block = text.substring(movie.keyStart, movie.close + 1)
val custom = customizationMovie(text)
val active = custom ?: olderMovie(text)
if (active != null) {
val block = text.substring(active.keyStart, active.close + 1)
val selectedId = scalar(block, "MovieID")
val selectedPath = scalar(block, "LocalPath")
if (selectedPath == path) {
if (selectedPath == path && custom != null) {
Log.i(TAG, "Steam startup movie is set to DroidDeck")
return
return true
}
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
val hasExplicitSelection =
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
if (!legacyDefault && hasExplicitSelection) {
if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) {
Log.i(TAG, "Steam startup movie selection preserved")
return
return false
}
val updated = setScalar(block, "MovieID", "0", newline)
.let { setScalar(it, "LocalPath", path, newline) }
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
} else {
}
// Later sections first, so the positions of earlier ones stay valid.
val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart }
for (target in targets) text = pointAt(text, target, path, newline)
if (olderMovie(text) == null) {
val steam = findSteamBlock(text)
if (steam == null) {
if (text.isNotBlank()) {
if (text.isNotBlank() && customizationMovie(text) == null) {
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
return
return false
}
if (text.isBlank()) {
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
}
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
} else {
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
@@ -286,22 +462,35 @@ object SessionFiles {
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
}
}
if (customizationMovie(text) == null) text = withCustomization(text, path, newline)
val staged = File(config.parentFile, config.name + ".staged")
try {
return try {
config.parentFile?.mkdirs()
staged.writeText(text)
if (!staged.renameTo(config)) {
staged.delete()
Log.e(TAG, "could not write Steam startup movie selection")
false
} else {
Log.i(TAG, "Steam startup movie default selected")
true
}
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write Steam startup movie selection", e)
false
}
}
/** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
private fun withCustomization(text: String, path: String, newline: String): String {
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' }
val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline)
return text.substring(0, lineStart) + section + newline + text.substring(lineStart)
}
private fun findSteamBlock(text: String): VdfBlock? {
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
@@ -66,15 +66,12 @@ object SessionLogShare {
return zip
}
/** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubForShare(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
val w = z.bufferedWriter()
LogRedactor.scrubForShare(f, w)
w.flush()
z.closeEntry()
}
@@ -118,6 +118,20 @@ object SessionPrefs {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
}
fun storesTabOrder(context: Context): List<String> = idList(prefs(context).getString("storesTabOrder", null))
fun setStoresTabOrder(context: Context, order: List<String>) {
prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply()
}
fun storesTabHidden(context: Context): Set<String> = idList(prefs(context).getString("storesTabHidden", null)).toSet()
fun setStoresTabHidden(context: Context, hidden: Set<String>) {
prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply()
}
private fun idList(raw: String?): List<String> = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() }
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "capsules", "list")
@@ -168,6 +182,7 @@ object SessionPrefs {
const val TOUCH_AUTO = "auto"
const val TOUCH_PAD = "touchpad"
const val TOUCH_DIRECT = "direct"
const val TOUCH_GESTURES = "gestures"
const val TOUCH_OFF = "off"
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
@@ -527,6 +542,17 @@ object SessionPrefs {
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100)
fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() }
/** How long the boot movie plays, in seconds; 0 plays all of it. */
fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0)
fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() }
/** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */
fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false)
fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() }
/** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */
fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null)
fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() }
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
fun setBootMovieCustomName(context: Context, name: String?) {
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
@@ -878,6 +904,11 @@ object SessionPrefs {
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun displayMode(context: Context, mode: String, program: String?): String =
if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) &&
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
else prefMode(mode)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
@@ -21,6 +22,8 @@ import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.SessionActivity
@@ -56,6 +59,7 @@ class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
private var deckBinds: List<String> = emptyList()
private var inputSysfs: InputSysfsComponent? = null
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
@@ -75,11 +79,13 @@ class SessionService : Service() {
private var signInHold = false
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible)
private var screenOn = true
private var sessionDisplayId = Display.DEFAULT_DISPLAY
private var manualPauseRequested = false
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepIdle = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
@@ -93,8 +99,8 @@ class SessionService : Service() {
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> screenOn = false
Intent.ACTION_SCREEN_ON -> screenOn = true
Intent.ACTION_SCREEN_OFF -> screenOn = readScreenOn(false)
Intent.ACTION_SCREEN_ON -> screenOn = readScreenOn(true)
else -> return
}
if (screenOn) resumeSteamSleepOnReturn()
@@ -102,17 +108,49 @@ class SessionService : Service() {
updateSuspendPolicy()
}
}
private var displayListenerRegistered = false
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (displayId != sessionDisplayId) return
sessionDisplayId = Display.DEFAULT_DISPLAY
refreshScreenOn()
}
override fun onDisplayChanged(displayId: Int) {
if (displayId == sessionDisplayId && displayId != Display.DEFAULT_DISPLAY) refreshScreenOn()
}
}
private fun readScreenOn(interactive: Boolean? = null): Boolean {
val id = sessionDisplayId
val state = if (id == Display.DEFAULT_DISPLAY) null
else (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.getDisplay(id)?.state
return sessionScreenOn(id == Display.DEFAULT_DISPLAY,
interactive ?: (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true, state)
}
private fun refreshScreenOn() {
val on = readScreenOn()
if (on == screenOn) return
screenOn = on
if (on) resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
}
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
screenOn = power?.isInteractive ?: true
screenOn = readScreenOn()
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
})
screenReceiverRegistered = true
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.let {
it.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
displayListenerRegistered = true
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -161,12 +199,19 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
screenOn = readScreenOn()
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_ACTIVITY_DISPLAY -> {
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
refreshScreenOn()
return START_NOT_STICKY
}
ACTION_ACTIVITY_HIDDEN -> {
activityVisible = false
suspendAttemptFailed = false
@@ -230,7 +275,8 @@ class SessionService : Service() {
activityVisible = true
xrActivityVisible = false
signInHold = false
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
sessionDisplayId = intent?.getIntExtra(EXTRA_DISPLAY_ID, Display.DEFAULT_DISPLAY) ?: Display.DEFAULT_DISPLAY
screenOn = readScreenOn()
manualPauseRequested = false
suspendDownloadNow = false
steamDownloadMonitor.reset()
@@ -293,14 +339,13 @@ class SessionService : Service() {
return lines
}
private fun tuDebug(linuxDriverId: String): String? {
val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
if (!override.isNullOrEmpty()) return override
if (SessionPrefs.tuSysmem(this)) return "sysmem"
if (linuxDriverId.isEmpty()) return null
private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() }
private fun sysmemDriver(linuxDriverId: String): Boolean {
if (linuxDriverId.isEmpty()) return false
val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null
return name.contains("710-720") || name.contains("710_720")
}
@@ -319,7 +364,9 @@ class SessionService : Service() {
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
SessionFiles.stage(this, root, startsSession = true)
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
SessionArtifacts.finishAbandoned(this)
@@ -365,6 +412,7 @@ class SessionService : Service() {
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
inputSysfs = null
if (controllersOn) {
addControllerEnvironment(guest, fakeInputDir, sessionDir)
SessionState.padBridge?.syncConnectedControllers()
@@ -575,7 +623,9 @@ class SessionService : Service() {
)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
val tuOverride = tuDebugOverride()
val tuDriver = sysmemDriver(linuxDriverId)
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
@@ -603,8 +653,9 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
extraEnv().forEach { guest.add(it) }
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
@@ -680,6 +731,7 @@ class SessionService : Service() {
val padType = SessionPrefs.steamController(this)
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
InputSysfsComponent.padTypeChanged(SessionPrefs.padType(padType))
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
}
@@ -693,6 +745,9 @@ class SessionService : Service() {
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
guest.add("FAKE_EVDEV_LOG=1")
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"),
File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM)
.takeIf { it.prepare() }
SessionState.fakeInputDir = fakeInputDir
}
@@ -707,6 +762,11 @@ class SessionService : Service() {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
if (controllersOn) inputSysfs?.let {
it.attach(this)
components.add(it)
binds.addAll(it.binds())
}
val battery = BatteryComponent(
File(filesDir, "session/sys/power_supply"),
File(LinuxRuntime.rootDir(this), "run/vpower"),
@@ -1016,10 +1076,10 @@ class SessionService : Service() {
private fun resumeSession(panelVisible: Boolean = true) {
if (panelVisible) activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
screenOn = readScreenOn()
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
@@ -1219,16 +1279,22 @@ class SessionService : Service() {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
if (!steamPause) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1236,7 +1302,7 @@ class SessionService : Service() {
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"screenOn" to screenOn, "display" to sessionDisplayId, "steamSleep" to (steamSleepToken != null),
"xrActivityVisible" to xrActivityVisible,
"nativeSleep" to (nativeSleepToken != null),
))
@@ -1296,7 +1362,7 @@ class SessionService : Service() {
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
@@ -1307,13 +1373,14 @@ class SessionService : Service() {
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle))
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
@@ -1339,6 +1406,7 @@ class SessionService : Service() {
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = false
}
private fun requestSteamWake() {
@@ -1498,6 +1566,10 @@ class SessionService : Service() {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
}
if (displayListenerRegistered) {
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.unregisterDisplayListener(displayListener)
displayListenerRegistered = false
}
super.onDestroy()
}
@@ -1665,6 +1737,7 @@ class SessionService : Service() {
private const val TAG = "SessionService"
private const val TRACER_NICE = -6
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
@@ -1684,6 +1757,8 @@ class SessionService : Service() {
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_ACTIVITY_DISPLAY = "com.droiddeck.launcher.ACTIVITY_DISPLAY"
private const val EXTRA_DISPLAY_ID = "displayId"
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD"
@@ -1720,8 +1795,10 @@ class SessionService : Service() {
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
displayId: Int = Display.DEFAULT_DISPLAY,
) {
val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode)
.putExtra(EXTRA_DISPLAY_ID, displayId)
if (program != null) intent.putExtra(EXTRA_PROGRAM, program)
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
@@ -1742,10 +1819,15 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN))
}
fun setActivityVisible(context: Context, visible: Boolean) {
fun setActivityVisible(context: Context, visible: Boolean, displayId: Int = Display.DEFAULT_DISPLAY) {
if (!SessionState.running) return
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action))
context.startService(Intent(context, SessionService::class.java).setAction(action).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setActivityDisplay(context: Context, displayId: Int) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setXrActivityVisible(context: Context, visible: Boolean) {
@@ -2,14 +2,22 @@ package com.droiddeck.launcher.session
import android.os.Process
import java.io.File
import java.io.IOException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
internal class SessionSuspendController(
private val sessionRoot: () -> Int,
private val helperRoots: () -> List<Int>,
private val suspendAudio: () -> Unit,
private val resumeAudio: () -> Unit,
private val procRoot: File = File("/proc"),
private val signal: (Int, Int) -> Unit = Process::sendSignal,
private val threadSignal: (Int, Int, Int) -> Unit = { pid, tid, signal -> ThreadSignals.tgkill(pid, tid, signal) },
private val sleep: (Long) -> Unit = Thread::sleep,
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
) {
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
@@ -24,96 +32,95 @@ internal class SessionSuspendController(
val parent: Int,
val started: Long,
val state: Char,
val tracer: Int,
)
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long)
@Volatile private var closing = false
private var frozen = emptyList<ProcessRecord>()
fun freeze(completion: (Boolean) -> Unit) {
submit {
if (closing) {
completion(false)
return@submit
}
if (frozen.isNotEmpty()) {
val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone)
if (!fullyFrozen && restore(frozen)) frozen = emptyList()
completion(fullyFrozen)
return@submit
}
val records = LinkedHashMap<Pair<Int, Long>, ProcessRecord>()
val root = sessionRoot()
if (root <= 1) {
completion(false)
return@submit
}
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root }.distinct()
var stablePasses = 0
submit({
val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started }
var complete = false
repeat(10) { pass ->
if (closing) {
restore(records.values.toList())
runAudio(resumeAudio)
completion(false)
return@submit
var interrupted = false
try {
val root = records.values.firstOrNull { it.role == Role.SESSION_ROOT }
?: readProcess(sessionRoot())?.takeIf { it.pid > 1 }?.let {
ProcessRecord(it.pid, it.started, Role.SESSION_ROOT)
} ?: throw IOException("Session root is unavailable")
records[root.pid to root.started] = root
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root.pid }.distinct()
for (helper in helpers) {
if (records.values.any { it.pid == helper && it.role == Role.HELPER_ROOT }) continue
readProcess(helper)?.let {
records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT)
}
}
val includeRoots = pass >= 2
addSnapshot(records, root, helpers, includeRoots)
stop(records.values.toList(), includeRoots)
Thread.sleep(if (pass == 0) 70L else 35L)
val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT }
if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) {
stablePasses++
if (stablePasses >= 2) complete = true
} else {
stablePasses = 0
val deadline = nowMillis() + FREEZE_TIMEOUT_MS
var previous: Map<TaskIdentity, Long>? = null
for (pass in 0 until FREEZE_MAX_PASSES) {
if (closing || nowMillis() >= deadline) break
try {
if (readProcess(root.pid)?.started != root.started) break
addSnapshot(records, root.pid, helpers)
if (readProcess(root.pid)?.started != root.started) break
stop(records.values.toList())
sleep(if (pass == 0) 70L else 35L)
if (closing || nowMillis() >= deadline) break
val current = stoppedSnapshot(records.values.toList(), root)
if (current != null && current == previous) {
complete = true
break
}
previous = current
} catch (_: IOException) {
previous = null
sleep(35L)
}
}
}
if (complete) {
} catch (_: InterruptedException) {
interrupted = true
Thread.interrupted()
} catch (_: Exception) {
} finally {
frozen = records.values.toList()
completion(true)
} else {
val restored = restore(records.values.toList())
runAudio(resumeAudio)
if (!restored) frozen = records.values.toList()
completion(false)
if (!complete) {
if (restoreSafely(frozen)) frozen = emptyList()
runAudio(resumeAudio)
}
if (interrupted) Thread.currentThread().interrupt()
}
}
completion(complete)
}, { completion(false) })
}
fun resume(completion: (Boolean) -> Unit) {
submit {
submit({
val records = frozen
runAudio(resumeAudio)
val resumed = restore(records)
val resumed = restoreSafely(records)
if (resumed) frozen = emptyList()
completion(resumed)
}
}, { completion(false) })
}
fun closeAndResume(completion: () -> Unit) {
closing = true
submit {
restore(frozen)
frozen = emptyList()
submit({
if (restoreSafely(frozen)) frozen = emptyList()
completion()
executor.shutdown()
}
}, completion)
}
private fun submit(task: () -> Unit) {
private fun submit(task: () -> Unit, rejected: () -> Unit) {
try {
executor.execute {
try {
task()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
} catch (_: RuntimeException) {
task()
executor.execute(task)
} catch (_: RejectedExecutionException) {
rejected()
}
}
@@ -128,23 +135,25 @@ internal class SessionSuspendController(
records: MutableMap<Pair<Int, Long>, ProcessRecord>,
root: Int,
helpers: List<Int>,
includeRoots: Boolean,
) {
val processes = readProcesses()
val byPid = processes.associateBy { it.pid }
for (process in descendants(root, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD)
val rootRecord = records.values.first { it.role == Role.SESSION_ROOT }
if (byPid[root]?.started != rootRecord.started) throw IOException("Session root exited or changed")
val liveHelpers = helpers.filter { pid ->
records.values.any { it.pid == pid && it.role == Role.HELPER_ROOT && it.started == byPid[pid]?.started }
}
for (helper in helpers) {
for (process in descendants(root, processes, byPid) + processes.filter { it.tracer == root }) {
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.SESSION_CHILD))
}
for (helper in liveHelpers) {
for (process in descendants(helper, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD)
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.HELPER_CHILD))
}
}
if (includeRoots) {
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in helpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
}
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in liveHelpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
}
}
@@ -168,36 +177,84 @@ internal class SessionSuspendController(
}
private fun readProcesses(): List<ProcessInfo> =
File("/proc").listFiles().orEmpty().mapNotNull { entry ->
(procRoot.listFiles() ?: throw IOException("Cannot enumerate processes")).mapNotNull { entry ->
val pid = entry.name.toIntOrNull() ?: return@mapNotNull null
readProcess(pid)
try { readProcess(pid) } catch (_: IOException) { null }
}
private fun readProcess(pid: Int): ProcessInfo? {
val stat = try {
File("/proc/$pid/stat").readText()
} catch (_: Exception) {
return null
}
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) return null
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
if (fields.size < 20) return null
return try {
ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first())
} catch (_: NumberFormatException) {
null
val base = File(procRoot, pid.toString())
try {
val fields = readStat(File(base, "stat"))
val tracer = File(base, "status").useLines { lines ->
lines.firstOrNull { it.startsWith("TracerPid:") }?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0
}
return ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first(), tracer)
} catch (error: IOException) {
if (procRoot.list()?.contains(pid.toString()) == false) return null
throw error
}
}
private fun stop(records: List<ProcessRecord>, includeRoots: Boolean) {
val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }
val session = records.filter { it.role == Role.SESSION_ROOT }
val helpers = records.filter { it.role == Role.HELPER_ROOT }
for (record in descendants) send(record, SIGSTOP)
if (includeRoots) {
for (record in session) send(record, SIGSTOP)
for (record in helpers) send(record, SIGSTOP)
private fun readStat(file: File): List<String> {
val stat = file.readText()
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) throw IOException("Invalid process stat: $file")
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
if (fields.size < 20) throw IOException("Incomplete process stat: $file")
return fields
}
private fun stoppedSnapshot(records: List<ProcessRecord>, root: ProcessRecord): Map<TaskIdentity, Long>? {
// PRoot can leave the leader stopped while native worker threads keep running.
val tasks = LinkedHashMap<TaskIdentity, Long>()
var stopped = true
for (record in records) {
val current = readProcess(record.pid)
if (current == null || current.started != record.started) {
if (record == root) throw IOException("Session root exited or changed")
continue
}
val entries = File(procRoot, "${record.pid}/task").listFiles()
?: throw IOException("Cannot enumerate threads: ${record.pid}")
if (entries.none { it.name.toIntOrNull() != null }) throw IOException("Empty thread inventory: ${record.pid}")
var liveRoot = false
for (entry in entries) {
val tid = entry.name.toIntOrNull() ?: continue
val fields = readStat(File(entry, "stat"))
val state = fields[0].first()
if (state != 'Z' && state != 'X') liveRoot = true
if (state != 'T' && state != 't' && state != 'Z' && state != 'X') stopped = false
tasks[TaskIdentity(record, tid, fields[19].toLong())] = fields[11].toLong() + fields[12].toLong()
}
if (record == root && !liveRoot) throw IOException("Session root has no live threads")
if (readProcess(record.pid)?.started != record.started) throw IOException("Process changed during inspection")
}
return tasks.takeIf { stopped && it.isNotEmpty() }
}
// PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
// thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
// signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
private fun stop(records: List<ProcessRecord>) {
for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }) {
stopThreads(record)
}
}
private fun stopThreads(record: ProcessRecord) {
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
if (current == null || current.started != record.started) return
for (entry in File(procRoot, "${record.pid}/task").listFiles().orEmpty()) {
val tid = entry.name.toIntOrNull() ?: continue
val state = try { readStat(File(entry, "stat"))[0].first() } catch (_: IOException) { continue }
if (state == 'T' || state == 't' || state == 'Z' || state == 'X') continue
try {
threadSignal(record.pid, tid, SIGSTOP)
} catch (_: Throwable) {
}
}
}
@@ -213,7 +270,7 @@ internal class SessionSuspendController(
for (record in ordered) send(record, SIGCONT)
val waiting = ordered.toMutableList()
repeat(RESTORE_CHECKS) { check ->
Thread.sleep(25L)
sleep(25L)
waiting.removeAll { isRunningOrGone(it) }
if (waiting.isEmpty()) return true
if (check % 8 == 7) for (record in waiting) send(record, SIGCONT)
@@ -221,30 +278,40 @@ internal class SessionSuspendController(
return false
}
private fun restoreSafely(records: List<ProcessRecord>): Boolean = try {
restore(records)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
false
} catch (_: Exception) {
false
}
private fun send(record: ProcessRecord, signal: Int) {
val current = readProcess(record.pid) ?: return
if (current.started != record.started) return
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
if (current == null || current.started != record.started) return
try {
Process.sendSignal(record.pid, signal)
this.signal(record.pid, signal)
} catch (_: Throwable) {
}
}
private fun isStoppedOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X'
}
private fun isRunningOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state != 'T' && current.state != 't'
val current = try { readProcess(record.pid) } catch (_: Exception) { return false }
if (current == null || current.started != record.started) return true
if (current.state == 'T' || current.state == 't') return false
// A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop.
return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry ->
entry.name.toIntOrNull() != null &&
try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false }
}
}
private companion object {
const val SIGSTOP = 19
const val SIGCONT = 18
const val RESTORE_CHECKS = 80
const val FREEZE_TIMEOUT_MS = 8_000L
const val FREEZE_MAX_PASSES = 256
}
}
@@ -1,4 +1,10 @@
package com.droiddeck.launcher.session
import android.view.Display
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
xrVisible || (panelVisible && screenOn)
internal fun sessionScreenOn(onDefaultDisplay: Boolean, interactive: Boolean, displayState: Int?): Boolean =
if (onDefaultDisplay || displayState == null || displayState == Display.STATE_UNKNOWN) interactive
else displayState == Display.STATE_ON || displayState == Display.STATE_VR || displayState == Display.STATE_ON_SUSPEND
@@ -21,7 +21,8 @@ object SettingsReset {
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
"bootMovieApplyNext", "bootMovieSteamSeen",
"questDefaultsV1",
)
@@ -0,0 +1,218 @@
package com.droiddeck.launcher.session
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */
object SteamDeckRepo {
private const val TAG = "SteamDeckRepo"
const val BASE = "https://steamdeckrepo.com"
/** The site shows twelve movies a page. */
const val PAGE_SIZE = 12
const val MAX_DOWNLOAD_BYTES = 120L shl 20
private const val MAX_JSON_BYTES = 1 shl 20
private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)"
private const val CACHE_MS = 10 * 60 * 1000L
enum class Sort(val key: String) {
TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"),
}
/** `null` is every device. */
val DEVICES = listOf("steam_deck", "steam_machine")
class Post(
val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String,
val seconds: Int, val likes: Int, val downloads: Int, val devices: List<String>, val url: String,
)
sealed class Page {
class Ok(val posts: List<Post>) : Page()
object Failed : Page()
}
/** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */
private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun parse(json: String): List<Post>? {
val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null
val out = ArrayList<Post>()
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val id = o.text("id")
val video = o.text("video")
if (id.isEmpty() || !video.startsWith("https://")) continue
val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty()
out.add(
Post(
id = id, title = o.text("title").ifBlank { id }, content = o.text("content"),
author = o.optJSONObject("user")?.text("steam_name").orEmpty(),
thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video,
seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"),
devices = devices, url = o.text("url"),
),
)
}
return out
}
fun url(sort: Sort, device: String?, search: String, page: Int = 1): String {
val query = StringBuilder("sort=").append(sort.key)
if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8"))
val term = search.trim()
if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20"))
if (page > 1) query.append("&page=").append(page)
return "$BASE/api/posts?$query"
}
private class Cached(val at: Long, val page: Page.Ok)
private val cache = HashMap<String, Cached>()
fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page {
val url = url(sort, device, search, page)
synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } }
val body = get(url) ?: return Page.Failed
val posts = parse(body) ?: return Page.Failed
val result = Page.Ok(posts.take(PAGE_SIZE))
synchronized(cache) { cache[url] = Cached(now, result) }
return result
}
/**
* The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a
* page past the end is empty and the last one is short. Null when the site cannot be reached.
*/
fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p ->
(fetch(sort, device, search, p) as? Page.Ok)?.posts?.size
}): Int? {
val first = count(1) ?: return null
if (first < PAGE_SIZE) return 1
var good = 1
var bad = -1
var probe = 2
while (probe <= cap) {
val n = count(probe) ?: return null
if (n == 0) { bad = probe; break }
if (n < PAGE_SIZE) return probe
good = probe
probe *= 2
}
if (bad < 0) return good
while (bad - good > 1) {
val mid = (good + bad) / 2
val n = count(mid) ?: return null
if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid
}
return good
}
/** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */
internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(16 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
if (out.size() + n > limit) return null
out.write(buffer, 0, n)
}
return out.toByteArray()
}
private fun get(url: String): String? {
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json")
}
if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) }
} catch (e: Exception) {
Log.w(TAG, "request failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { connection?.disconnect() }
}
}
/** Fetches a small picture such as a cover; null when it cannot be had. */
fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? {
if (!url.startsWith("https://")) return null
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null
else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } }
} catch (e: Exception) {
null
} finally {
runCatching { connection?.disconnect() }
}
}
/**
* Downloads a movie to [target] through the site's own download address, so the site counts it like a download from
* its website, and falls back to the video's address. True when a file was saved.
*/
fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean {
for (url in listOf("$BASE/post/download/${post.id}", post.video)) {
if (save(url, target, onProgress)) return true
}
return false
}
private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean {
var connection: HttpURLConnection? = null
val staged = File(target.parentFile, target.name + ".part")
return try {
target.parentFile?.mkdirs()
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299) return false
val type = connection.contentType.orEmpty().lowercase()
if (type.startsWith("text/") || type.contains("json")) return false
val total = connection.contentLengthLong
if (total > MAX_DOWNLOAD_BYTES) return false
var done = 0L
var lastPercent = -1
connection.inputStream.use { input ->
staged.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
done += n
if (done > MAX_DOWNLOAD_BYTES) return false
output.write(buffer, 0, n)
if (total > 0) {
val percent = (done * 100 / total).toInt()
if (percent != lastPercent) { lastPercent = percent; onProgress(percent) }
}
}
}
}
done > 0 && (total <= 0 || done == total) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "download failed: ${e.javaClass.simpleName}")
false
} finally {
runCatching { connection?.disconnect() }
staged.delete()
}
}
}
@@ -0,0 +1,9 @@
package com.droiddeck.launcher.session
internal object ThreadSignals {
init {
System.loadLibrary("sdnative")
}
@JvmStatic external fun tgkill(tgid: Int, tid: Int, signal: Int): Int
}
@@ -37,7 +37,7 @@ object StoreLaunchRequests {
}
fun stop() {
Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start()
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -52,9 +52,10 @@ object StoreLaunchRequests {
}
val reply = when (request.optString("op")) {
"xbox-released" -> {
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id"))
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease"))
JSONObject().put("ok", true)
}
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
@@ -0,0 +1,9 @@
package com.droiddeck.launcher.stores
data class StoreTab(val store: Store, val shown: Boolean)
fun arrangeStoreTabs(order: List<String>, hidden: Set<String>): List<StoreTab> {
val known = Store.entries.associateBy { it.id }
val listed = order.mapNotNull { known[it] }.distinct()
return (listed + Store.entries.filterNot { it in listed }).map { StoreTab(it, it.id !in hidden) }
}
@@ -168,10 +168,12 @@ object StoresState {
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
background("stores-signout") {
backends[store]?.signOut(app)
val backend = backends[store]
backend?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else StoreAccounts.clear(app, store)
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
main.post {
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPrefs
@@ -24,7 +25,8 @@ import java.util.concurrent.TimeUnit
object GogComet {
private const val TAG = "GogComet"
private const val BIN = "/usr/local/lib/droiddeck-comet/comet"
private const val DIR = "/usr/local/lib/droiddeck-comet"
private const val BIN = "$DIR/comet"
private val lock = Any()
@Volatile private var process: Process? = null
private var pid = -1
@@ -41,7 +43,27 @@ object GogComet {
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
if (on) AddonPack.COMET.ensure(context)
}
private fun stage(context: Context): Boolean {
if (!AddonPack.COMET.installed(context)) {
AddonPack.COMET.ensure(context)
return false
}
val pack = AddonPack.COMET.dir(context)
val root = File(LinuxRuntime.rootDir(context), DIR.removePrefix("/"))
val packaged = File(pack, "version").readText()
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return true
root.mkdirs()
pack.listFiles().orEmpty().filter { it.isFile && it.name != "version" && it.name != AddonPack.COMPLETE }.forEach { source ->
val staged = File(root, source.name + ".staged")
source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } }
staged.setExecutable(true, false)
check(staged.renameTo(File(root, source.name)))
}
File(root, "version").writeText(packaged)
return true
}
fun begin() = synchronized(lock) {
@@ -113,7 +135,8 @@ object GogComet {
return JSONObject().put("ok", true).put("ready", true).put("reason", "ready")
}
stopLocked()
if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
if (!runCatching { stage(context) }.onFailure { Log.w(TAG, "staging failed", it) }.getOrDefault(false) ||
!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
val data = File(context.filesDir, "gog-achievements").apply { mkdirs() }
val ready = CountDownLatch(1)
val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(),
@@ -19,12 +19,15 @@ object XboxBackend : StoreBackend {
context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@Synchronized override fun signOut(context: Context) {
generation++
XboxCredentials.invalidate(context)
XboxLibraryCache.clear(context)
XboxRuntime.stop()
access = emptyMap()
override fun signOut(context: Context) {
XboxCredentials.invalidate(context) {
synchronized(this) {
generation++
access = emptyMap()
}
XboxLibraryCache.clear(context)
XboxRuntime.stop()
}
StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
}
@@ -51,8 +54,12 @@ object XboxBackend : StoreBackend {
}
}
if (cached != null && !cached.needsRefresh(force)) return@Thread
val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market)
val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() }
val response = Xodus.run(app, listOf("library", "--json", "--market", market))
val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") })
val products = XboxCatalog.rawProducts(licenses.keys.toList(), market)
val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() }
StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " +
"filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}")
synchronized(this) {
if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread
access = licenses
@@ -11,10 +11,25 @@ internal object XboxCatalog {
private val productId = Regex("[A-Z0-9]{12}")
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map<String, String> {
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map<String, String> {
require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds
require(age in -60..299)
val memberships: Set<String>? = if (json.has("subscriptions")) {
val subscriptions = json.getJSONArray("subscriptions")
require(subscriptions.length() <= plans.size)
val seen = HashSet<String>()
buildSet {
for (i in 0 until subscriptions.length()) {
val row = subscriptions.getJSONObject(i)
val id = row.getString("productId")
require(id in plans && seen.add(id))
val status = row.getString("status")
val expiry = timestamp(row.getString("endDate"))
if (status == "Active" && expiry.isAfter(now)) add(id)
}
}
} else null
val array = json.getJSONArray("entitlements")
require(array.length() <= 10_000)
val rows = LinkedHashMap<String, JSONObject>()
@@ -28,18 +43,24 @@ internal object XboxCatalog {
(row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId")))
if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row
}
fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") &&
row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now)
fun activePlan(id: String) = rows[id]?.let(::active) ?: (id in memberships.orEmpty())
fun kind(id: String, visited: Set<String> = emptySet()): String? {
if (id in blocked || id in visited || visited.size >= 16) return null
val row = rows[id] ?: return null
if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") ||
row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null
if (!active(row)) return null
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
if (parent == null) return if (id in plans) null else "owned"
if (parent in blocked) return null
if (parent in plans) return "gamepass"
if (parent in plans) return if (activePlan(parent)) "gamepass" else null
return kind(parent, visited + id)
}
return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
val missingPlan = rows.values.count { row -> row.optString("satisfiedByProductId").let { it in plans && !activePlan(it) } }
report("library entitlements=${array.length()} invalid=${array.length() - rows.size} restricted=${rows.values.count { !active(it) }} " +
"unverified-plan=$missingPlan owned=${result.values.count { it == "owned" }} gamepass=${result.values.count { it == "gamepass" }}")
return result
}
private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant()
@@ -10,11 +10,68 @@ import java.io.Closeable
import java.io.InputStream
import java.util.UUID
internal class XboxCredentials(private val app: Context) : Closeable {
internal class XboxCredentials(private val app: Context, expectedGeneration: String? = null) : Closeable {
val name = "droiddeck-xbox-${UUID.randomUUID()}"
private val generation = storage(app) { epoch(app) }
private val generation = lifecycle(app, expectedGeneration) { it }
private val server = LocalServerSocket(name)
@Volatile private var closed = false
private val bound = java.util.concurrent.CountDownLatch(1)
private var backend: Process? = null
private var binaryHash = byteArrayOf()
private lateinit var binary: java.io.File
private val peers = java.util.concurrent.ConcurrentHashMap<Int, String>()
fun bind(process: Process, file: java.io.File, digest: ByteArray) {
backend = process
binary = file.canonicalFile
binaryHash = digest
bound.countDown()
}
private fun rejected(reason: String): Boolean {
android.util.Log.w("XboxCredentials", "Rejected credential peer: $reason")
return false
}
private fun authorized(pid: Int): Boolean {
if (!bound.await(5, java.util.concurrent.TimeUnit.SECONDS) || closed) return rejected("unbound")
val process = backend ?: return rejected("missing-child")
if (!process.isAlive) return rejected("exited-child")
val root = com.droiddeck.launcher.core.HostProcess.pidOf(process)
if (root <= 0 || pid <= 0) return rejected("missing-process-id")
val identity = identity(pid, root) ?: return rejected("foreign-backend")
if (peers[pid] == identity) return true
val digest = java.security.MessageDigest.getInstance("SHA-256")
binary.inputStream().use { input ->
val buffer = ByteArray(65536)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
if (!java.security.MessageDigest.isEqual(binaryHash, digest.digest())) return rejected("different-executable")
peers[pid] = identity
return true
}
private fun identity(pid: Int, root: Int): String? {
if (root <= 0 || pid <= 0) return null
val status = java.io.File("/proc/$pid/status").readLines()
val tracer = status.first { it.startsWith("TracerPid:") }.substringAfter(':').trim().toInt()
val tracerGroup = java.io.File("/proc/$tracer/status").readLines()
.first { it.startsWith("Tgid:") }.substringAfter(':').trim().toInt()
if (tracerGroup != root) return null
val executable = android.system.Os.stat(binary.path)
if (!mapsBackend(java.io.File("/proc/$pid/maps").readText(), binary.path, executable.st_ino))
return null
return java.io.File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19] + ":${executable.st_dev}:${executable.st_ino}"
}
fun signalBackend(signal: Int) {
val process = backend ?: return
if (!process.isAlive) return
val root = com.droiddeck.launcher.core.HostProcess.pidOf(process)
peers.forEach { (pid, expected) -> runCatching {
if (identity(pid, root) == expected) android.system.Os.kill(pid, signal)
} }
}
init {
Thread({
@@ -23,12 +80,17 @@ internal class XboxCredentials(private val app: Context) : Closeable {
socket.use {
it.soTimeout = 10_000
val reply = runCatching {
check(it.peerCredentials.uid == android.os.Process.myUid())
val peer = it.peerCredentials
check(peer.uid == android.os.Process.myUid() && authorized(peer.pid))
val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152))
storage(app) {
check(epoch(app) == generation)
answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152)))
check(!closed && epoch(app) == generation && authorized(peer.pid))
answer(request)
}
}.getOrElse { JSONObject().put("ok", false) }
}.getOrElse {
android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}")
JSONObject().put("ok", false)
}
runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) }
}
}
@@ -58,14 +120,25 @@ internal class XboxCredentials(private val app: Context) : Closeable {
return result
}
override fun close() { closed = true; runCatching { server.close() } }
override fun close() { closed = true; bound.countDown(); runCatching { server.close() } }
companion object {
val lock = Any()
internal fun mapsBackend(maps: String, path: String, inode: Long): Boolean = maps.lineSequence().any { line ->
val fields = line.trim().split(Regex("\\s+"), limit = 6)
fields.size == 6 && 'x' in fields[1] && fields[4].toLongOrNull() == inode &&
runCatching { java.io.File(fields[5]).canonicalPath == path }.getOrDefault(false)
}
private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("")
fun invalidate(app: Context) = storage(app) {
fun <T> lifecycle(app: Context, expected: String? = null, block: (String) -> T): T = storage(app) {
val current = epoch(app)
check(expected == null || expected == current) { "Xbox account changed during the request" }
block(current)
}
fun invalidate(app: Context, revoked: () -> Unit = {}) = storage(app) {
java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString())
StoreAccounts.clear(app, Store.XBOX)
revoked()
}
fun <T> storage(app: Context, block: () -> T): T = synchronized(lock) {
java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file ->
@@ -0,0 +1,38 @@
package com.droiddeck.launcher.stores.xbox
import java.io.Closeable
import java.util.UUID
internal class XboxLaunchLeases(private val now: () -> Long) {
private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null)
private val leases = HashMap<String, Lease>()
@Synchronized fun active(id: String) = leases.values.any { it.id == id }
@Synchronized fun begin(id: String, account: String, session: String): String {
check(!active(id)) { "This Xbox game is already starting or running." }
val token = UUID.randomUUID().toString()
leases[token] = Lease(id, account, session, now() + 180_000)
return token
}
@Synchronized fun attach(token: String, child: Closeable): Boolean {
val lease = leases[token] ?: return false
lease.child = child
return true
}
@Synchronized fun renew(token: String, id: String, account: String?, session: String?): Boolean {
val lease = leases[token] ?: return false
if (lease.id != id || lease.account != account || lease.session != session || lease.expires <= now()) return false
lease.expires = now() + 60_000
return true
}
@Synchronized fun release(token: String): Closeable? = leases.remove(token)?.child
@Synchronized fun stale(account: String?, session: String?, suspended: Boolean): List<Closeable> {
if (suspended) leases.values.filter { it.account == account && it.session == session }
.forEach { it.expires = maxOf(it.expires, now() + 60_000) }
val expired = leases.filterValues { it.account != account || it.session != session || (!suspended && it.expires <= now()) }.keys
return expired.mapNotNull { leases.remove(it)?.child }
}
@Synchronized fun clear(): List<Closeable> {
return leases.values.mapNotNull { it.child }.also { leases.clear() }
}
}
@@ -0,0 +1,40 @@
package com.droiddeck.launcher.stores.xbox
import java.io.File
import java.nio.file.Files
import java.nio.file.FileVisitResult
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
internal class XboxLaunchViews {
private val recovered = HashSet<String>()
@Synchronized fun create(files: File): File {
val parent = File(files, "xbox-runtime/views").canonicalFile
check(parent == File(files.canonicalFile, "xbox-runtime/views")) { "Xbox launch storage contains a redirected path" }
check(parent.mkdirs() || parent.isDirectory)
if (parent.path !in recovered) {
parent.listFiles()?.filter { it.name.startsWith("launch-") }?.forEach(::remove)
recovered.add(parent.path)
}
return Files.createTempDirectory(parent.toPath(), "launch-").toFile()
}
companion object {
fun remove(directory: File) {
if (!Files.exists(directory.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walkFileTree(directory.toPath(), object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.deleteIfExists(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult {
if (error != null) throw error
Files.deleteIfExists(dir)
return FileVisitResult.CONTINUE
}
})
}
}
}
@@ -48,7 +48,8 @@ class XboxLoginActivity : ComponentActivity() {
Thread({
if (closed) return@Thread
try {
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process ->
val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it }
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process ->
child = process
if (closed) return@use
Xodus.diagnostics(process)
@@ -99,9 +100,9 @@ class XboxLoginActivity : ComponentActivity() {
}
child = null
if (closed) return@Thread
val account = Xodus.run(applicationContext, listOf("account", "--json"))
val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration)
check(account.optBoolean("stored"))
XboxCredentials.storage(this) {
XboxCredentials.lifecycle(this, accountGeneration) {
val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing")
record.put("username", account.getString("username"))
StoreAccounts.write(this, Store.XBOX, record)
@@ -7,40 +7,110 @@ import org.json.JSONObject
import java.io.File
internal object XboxRuntime {
private val state = Any()
private val startup = Any()
private var child: Xodus.Child? = null
private var owner: String? = null
private val activeGames = HashMap<String, Int>()
@Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0
@Synchronized fun released(id: String) {
val count = activeGames.getOrDefault(id, 0)
if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1
}
private var serviceSocket: File? = null
private var generation = 0L
private val views = XboxLaunchViews()
private val launches = XboxLaunchLeases { android.os.SystemClock.uptimeMillis() }
fun isActive(id: String): Boolean = launches.active(id)
fun released(token: String) { runCatching { launches.release(token)?.close() } }
fun renew(app: Context, id: String, token: String): Boolean = launches.renew(token, id,
StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running })
@Synchronized fun prepare(app: Context, id: String): JSONObject {
private data class Authorization(val account: String, val token: String, val runtime: Long, val credentials: String)
fun prepare(app: Context, id: String): JSONObject {
check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" }
check(SessionState.running) { "No running DroidDeck session" }
check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" }
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
val socket = File(app.filesDir, "xbox-runtime/xodus.sock")
if (child?.process?.isAlive != true || owner != account) {
stop()
socket.delete()
val service = Xodus.start(app, emptyList(), service = true)
child = service; owner = account
Xodus.diagnostics(service)
Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start()
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
if (!socket.exists() || !service.process.isAlive || !SessionState.running) {
stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.")
}
val session = SessionState.sessionId ?: error("No running DroidDeck session")
val game = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.store == Store.XBOX && it.id == id && it.isInstalled }?.let { folder to it }
}.firstOrNull() ?: error("Xbox game is not installed")
val (account, token, current, credentials) = XboxCredentials.lifecycle(app) { epoch ->
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
synchronized(state) { Authorization(account, launches.begin(id, account, session), generation, epoch) }
}
activeGames[id] = activeGames.getOrDefault(id, 0) + 1
return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket)
Thread({
while (isActive(id)) {
Thread.sleep(10_000)
launches.stale(StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running },
SessionState.suspended).forEach { runCatching { it.close() } }
}
}, "xbox-launch-expiry").start()
try {
val socket = service(app, account, current, credentials)
check(synchronized(state) { generation == current }) { "Xbox launch was cancelled" }
val market = game.second.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
val directory = views.create(app.filesDir)
val process = try {
Xodus.start(app, listOf("run", game.first.path, "droiddeck:prepare", "--exe", game.second.exe,
"--materialize-chain", "--market", market), launchDirectory = directory, credentialGeneration = credentials)
} catch (e: Exception) { XboxLaunchViews.remove(directory); throw e }
if (!launches.attach(token, process)) { process.close(); error("Xbox launch was cancelled") }
Xodus.diagnostics(process)
val result = java.util.concurrent.FutureTask {
val input = process.process.inputStream.buffered()
var line: String
do { line = XboxCredentials.readLine(input, 131_072) } while (!line.startsWith("DROIDDECK_LAUNCH_V1 "))
JSONObject(line.removePrefix("DROIDDECK_LAUNCH_V1 "))
}
Thread(result, "xbox-launch-view").start()
val view = result.get(90, java.util.concurrent.TimeUnit.SECONDS)
check(renew(app, id, token) && synchronized(state) { generation == current }) { "Xbox launch was cancelled" }
return JSONObject().put("ok", true).put("socket", socket.path).put("lease", token)
.put("exe", view.getString("exe")).put("playfab", view.opt("playfab"))
} catch (e: Exception) { released(token); throw e }
}
@Synchronized fun stop() {
val process = child; child = null; owner = null; activeGames.clear()
runCatching { process?.close() }
internal fun newServiceSocket(files: File): File =
File(files, "xbox-runtime/s-${java.util.UUID.randomUUID().toString().replace("-", "")}/xodus.sock")
private fun service(app: Context, account: String, current: Long, credentials: String): File = synchronized(startup) {
val existing = synchronized(state) {
check(generation == current) { "Xbox launch was cancelled" }
serviceSocket?.takeIf { child?.process?.isAlive == true && owner == account }
}
val socket = existing ?: newServiceSocket(app.filesDir)
if (existing == null) {
val old = synchronized(state) { child.also { child = null; owner = null; serviceSocket = null } }
old?.close()
socket.delete()
val service = Xodus.start(app, emptyList(), service = true, runtimeDirectory = socket.parentFile!!, credentialGeneration = credentials)
val accepted = synchronized(state) {
(generation == current).also { if (it) { child = service; owner = account; serviceSocket = socket } }
}
if (!accepted) { service.close(); error("Xbox launch was cancelled") }
try {
Xodus.diagnostics(service)
Thread({ runCatching { service.process.inputStream.use { input ->
val buffer = ByteArray(8192)
while (input.read(buffer) >= 0) { Thread.yield() }
} } }, "xbox-service-output").start()
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
while (!socket.exists() && service.process.isAlive && SessionState.running &&
synchronized(state) { generation == current } && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
check(socket.exists() && service.process.isAlive && SessionState.running) {
"Xbox account service did not become ready. Check your sign-in and network connection."
}
} catch (e: Exception) {
synchronized(state) { if (child === service) { child = null; owner = null; serviceSocket = null } }
service.close()
throw e
}
}
socket
}
fun stop() {
val processes = synchronized(state) {
generation++
val all = launches.clear() + listOfNotNull(child)
child = null; owner = null; serviceSocket = null
all
}
Thread({ processes.forEach { runCatching { it.close() } } }, "xbox-runtime-stop").start()
}
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores.xbox
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
@@ -13,51 +14,70 @@ internal object Xodus {
const val DIR = "/usr/local/lib/droiddeck-xbox"
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
class Child(val process: Process, private val credentials: XboxCredentials) : Closeable {
val credentialSocket: String get() = credentials.name
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
@Synchronized fun send(frame: JSONObject) {
process.outputStream.write((frame.toString() + "\n").toByteArray())
process.outputStream.flush()
}
override fun close() {
process.destroy()
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
credentials.close()
try {
credentials.close()
runCatching { process.outputStream.close() }
if (!process.waitFor(2, TimeUnit.SECONDS)) {
credentials.signalBackend(android.system.OsConstants.SIGINT)
if (!process.waitFor(2, TimeUnit.SECONDS)) {
credentials.signalBackend(android.system.OsConstants.SIGKILL)
if (!process.waitFor(1, TimeUnit.SECONDS)) {
val pid = com.droiddeck.launcher.core.HostProcess.pidOf(process)
if (pid > 0) android.os.Process.killProcess(pid)
}
}
}
} finally {
runtimeDirectory?.let { directory ->
File(directory, "xodus.sock").delete()
directory.delete()
}
launchDirectory?.let(XboxLaunchViews::remove)
}
}
}
@Synchronized private fun stage(app: Context) {
@Synchronized private fun stage(app: Context): File {
check(AddonPack.XBOX.await(app)) { "Couldn't download the Xbox components. Check your connection and try again." }
val pack = AddonPack.XBOX.dir(app)
val root = File(LinuxRuntime.rootDir(app), DIR.removePrefix("/"))
val packaged = app.assets.open("linuxfs$DIR/version").bufferedReader().use { it.readText() }
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return
fun copy(relative: String) {
val entries = app.assets.list("linuxfs$DIR/$relative").orEmpty()
if (entries.isNotEmpty()) { entries.forEach { copy(if (relative.isEmpty()) it else "$relative/$it") }; return }
if (relative == "version") return
val packaged = File(pack, "version").readText()
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return pack
fun copy(source: File, relative: String) {
if (source.isDirectory) { source.listFiles().orEmpty().forEach { copy(it, if (relative.isEmpty()) it.name else "$relative/${it.name}") }; return }
if (relative == "version" || relative == AddonPack.COMPLETE) return
val file = File(root, relative)
file.parentFile?.mkdirs()
val staged = File(file.path + ".staged")
app.assets.open("linuxfs$DIR/$relative").use { input -> staged.outputStream().use { input.copyTo(it) } }
source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } }
staged.setExecutable(true, false)
check(staged.renameTo(file))
}
copy("")
copy(pack, "")
File(root, "version").writeText(packaged)
return pack
}
fun start(app: Context, args: List<String>, service: Boolean = false): Child {
fun start(app: Context, args: List<String>, service: Boolean = false, runtimeDirectory: File? = null, launchDirectory: File? = null, credentialGeneration: String? = null): Child {
check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." }
stage(app)
val pack = stage(app)
val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
val credentials = XboxCredentials(app)
val credentials = XboxCredentials(app, credentialGeneration)
try {
val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() }
val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() }
val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
"/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
"XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary,
) + args)
val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app))
if (launchDirectory != null) builder.environment()["TMPDIR"] = launchDirectory.path
builder.environment().apply {
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
@@ -65,12 +85,19 @@ internal object Xodus {
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
return Child(builder.start(), credentials)
val digest = java.security.MessageDigest.getInstance("SHA-256")
File(pack, binary.removePrefix("$DIR/")).inputStream().use { input ->
val buffer = ByteArray(65536)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
val process = builder.start()
credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest())
return Child(process, credentials, runtimeDirectory, launchDirectory)
} catch (e: Exception) { credentials.close(); throw e }
}
fun run(app: Context, args: List<String>, timeout: Long = 90): JSONObject {
start(app, args).use { child ->
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
start(app, args, credentialGeneration = credentialGeneration).use { child ->
val output = java.io.ByteArrayOutputStream()
var readError: Throwable? = null
val reader = Thread({
@@ -305,6 +305,7 @@ internal fun AppDialog(
modifier: Modifier = Modifier, maxWidth: Dp? = null,
width: Dp? = null, fitContent: Boolean = false,
back: (() -> Unit)? = null,
scrollable: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
val pal = LocalPalette.current
@@ -342,7 +343,7 @@ internal fun AppDialog(
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.controllerBack(back ?: close)
.then(modifier)
.verticalScroll(rememberScrollState())
.then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier)
.padding(horizontal = 18.dp, vertical = 16.dp),
content = content,
)
@@ -0,0 +1,695 @@
package com.droiddeck.launcher.ui
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.graphics.SurfaceTexture
import android.media.MediaPlayer
import android.net.Uri
import android.view.Surface
import android.view.TextureView
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.viewinterop.AndroidView
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.BootMovie
import com.droiddeck.launcher.session.BootMovieLibrary
import com.droiddeck.launcher.session.BootMovieReport
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamDeckRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private enum class MovieTab { INSTALLED, IMPORT, REPO, SOUND }
/** Downloads run here rather than in a tab, so switching tabs or closing the settings does not cancel one halfway. */
private val installScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
/** The downloads under way, by post id and percent; kept here so they are still known when the tab or the settings come back. */
private val installProgress = mutableStateMapOf<String, Int>()
/** What the movie list looks like right now: every movie and the one Steam will play. */
private class MovieSnapshot(val entries: List<BootMovieLibrary.Entry>, val selected: String)
/** The movie list, read again every couple of seconds so a movie picked, added or removed inside Steam shows up here too. */
@Composable
private fun rememberMovies(refresh: Int, onSelectionChanged: () -> Unit): MovieSnapshot? {
val app = LocalContext.current.applicationContext
var snapshot by remember { mutableStateOf<MovieSnapshot?>(null) }
val changed by androidx.compose.runtime.rememberUpdatedState(onSelectionChanged)
LaunchedEffect(refresh) {
while (true) {
val before = snapshot?.selected
snapshot = withContext(Dispatchers.IO) {
BootMovie.syncFromSteam(app)
MovieSnapshot(BootMovieLibrary.list(app), BootMovie.choice(app))
}
if (before != null && before != snapshot?.selected) changed()
delay(2_000)
}
}
return snapshot
}
@Composable
private fun TabChip(label: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(8.dp)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.clip(shape)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal)
.hoverable(src)
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = label; this.selected = selected }
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(label, fontSize = 12.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/** The "Boot movie" settings group: Installed movies, Import your own movies, SteamDeckRepo and Sound. */
@Composable
internal fun BootMovieGroup(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) {
var tab by rememberSaveable { mutableStateOf(MovieTab.INSTALLED) }
SettingsGroup(stringResource(R.string.boot_movie)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)) {
TabChip(stringResource(R.string.boot_movie_tab_installed), tab == MovieTab.INSTALLED, Modifier.weight(1f)) { tab = MovieTab.INSTALLED }
TabChip(stringResource(R.string.boot_movie_tab_import), tab == MovieTab.IMPORT, Modifier.weight(1f)) { tab = MovieTab.IMPORT }
TabChip(stringResource(R.string.boot_movie_tab_repo), tab == MovieTab.REPO, Modifier.weight(1f)) { tab = MovieTab.REPO }
TabChip(stringResource(R.string.boot_movie_tab_sound), tab == MovieTab.SOUND, Modifier.weight(1f)) { tab = MovieTab.SOUND }
}
when (tab) {
MovieTab.INSTALLED -> InstalledTab(a)
MovieTab.IMPORT -> ImportTab(s, a)
MovieTab.REPO -> RepoTab(a)
MovieTab.SOUND -> SoundTab(s, a, host)
}
}
}
@Composable
private fun InstalledTab(a: ModeSettingsActions) {
var browsing by remember { mutableStateOf(false) }
val snapshot = rememberMovies(0, a.onBootMovieChanged)
val current = snapshot?.let { snap -> snap.entries.firstOrNull { it.id == snap.selected }?.title }
val hint = current ?: stringResource(R.string.common_working)
SettingsRow(stringResource(R.string.boot_movie_installed), hint) {
SecondaryButton(stringResource(R.string.boot_movie_browse), onClick = { browsing = true })
}
Text(
stringResource(R.string.boot_movie_note), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp),
)
val app = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
var saving by remember { mutableStateOf(false) }
// The report is for bug reports, so it shows only while Debugging tools is on (read each time this tab opens).
val debugging = remember { com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(app) }
if (debugging) {
SettingsRow(stringResource(R.string.boot_movie_report), stringResource(R.string.boot_movie_report_hint)) {
SecondaryButton(stringResource(R.string.boot_movie_report_save), enabled = !saving, onClick = {
saving = true
scope.launch {
val file = withContext(Dispatchers.IO) { BootMovieReport.write(app) }
saving = false
Toast.makeText(
app,
file?.let { app.getString(R.string.boot_movie_report_saved, it.path) } ?: app.getString(R.string.boot_movie_report_failed),
Toast.LENGTH_LONG,
).show()
}
})
}
}
if (browsing) InstalledMoviesDialog(a) { browsing = false }
}
@Composable
private fun MovieCover(entry: BootMovieLibrary.Entry, modifier: Modifier = Modifier) {
val app = LocalContext.current.applicationContext
val cover by androidx.compose.runtime.produceState<File?>(null, entry.id, entry.title) {
value = withContext(Dispatchers.IO) { BootMovieLibrary.cover(app, entry) }
}
Box(modifier.aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0E1218))) {
cover?.let { AsyncImage(model = it, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
}
}
@Composable
private fun movieSourceLabel(source: String): String = stringResource(
when (source) {
BootMovieLibrary.SOURCE_DROIDDECK -> R.string.boot_movie_source_droiddeck
BootMovieLibrary.SOURCE_REPO -> R.string.boot_movie_source_repo
BootMovieLibrary.SOURCE_IMPORT -> R.string.boot_movie_source_import
BootMovieLibrary.SOURCE_POINTSHOP -> R.string.boot_movie_source_pointshop
else -> R.string.boot_movie_source_steam
},
)
/** The installed movies as a list with covers; the one Steam plays is highlighted, and the list follows changes made in Steam. */
@Composable
private fun InstalledMoviesDialog(a: ModeSettingsActions, onDismiss: () -> Unit) {
val app = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var refresh by remember { mutableIntStateOf(0) }
val snapshot = rememberMovies(refresh, a.onBootMovieChanged)
var picked by remember { mutableStateOf<String?>(null) }
var removing by remember { mutableStateOf<BootMovieLibrary.Entry?>(null) }
var filter by rememberSaveable { mutableStateOf<String?>(null) }
val selected = picked ?: snapshot?.selected
LaunchedEffect(snapshot?.selected) { picked = null }
removing?.let { entry ->
RemoveMovieDialog(
entry,
onConfirm = {
removing = null
scope.launch {
withContext(Dispatchers.IO) { BootMovieLibrary.remove(app, entry.id) }
a.onBootMovieChanged()
refresh++
}
},
onDismiss = { removing = null },
)
}
val firstRow = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
LaunchedEffect(snapshot != null, filter) {
if (snapshot != null && inputMode.inputMode == InputMode.Keyboard) focusSoon { firstRow }
}
AppDialog(shown, close, "boot-movies", wide = false, scrollable = false) {
val filters = listOf(
null to stringResource(R.string.store_all),
BootMovieLibrary.SOURCE_DROIDDECK to movieSourceLabel(BootMovieLibrary.SOURCE_DROIDDECK),
BootMovieLibrary.SOURCE_STEAM to movieSourceLabel(BootMovieLibrary.SOURCE_STEAM),
BootMovieLibrary.SOURCE_POINTSHOP to movieSourceLabel(BootMovieLibrary.SOURCE_POINTSHOP),
BootMovieLibrary.SOURCE_REPO to movieSourceLabel(BootMovieLibrary.SOURCE_REPO),
BootMovieLibrary.SOURCE_IMPORT to movieSourceLabel(BootMovieLibrary.SOURCE_IMPORT),
)
var filterOpen by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
Text(
stringResource(R.string.boot_movie_installed), fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
Box {
androidx.compose.runtime.CompositionLocalProvider(LocalChipMinWidth provides 100.dp) {
ValueChip(filters.first { it.first == filter }.second, filterOpen) { filterOpen = !filterOpen }
}
AnchoredMenu(filterOpen, onDismiss = { filterOpen = false }, compact = true) { firstItemFocus ->
filters.forEachIndexed { index, (source, label) ->
MenuItem(label, checked = filter == source, focusRequester = if (index == 0) firstItemFocus else null) {
filter = source
filterOpen = false
}
}
}
}
SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = MaterialTheme.colorScheme.error, onClick = close)
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) {
itemsIndexed(snapshot?.entries.orEmpty().filter { filter == null || it.source == filter }, key = { _, entry -> entry.id }) { index, entry ->
ListRow(accent = entry.id == selected, tall = true, focus = if (index == 0) firstRow else null, onClick = { picked = entry.id; a.onBootMovie(entry.id) }) {
MovieCover(entry, Modifier.size(width = 96.dp, height = 54.dp))
Column(Modifier.weight(1f)) {
Text(entry.title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground,
maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(movieSourceLabel(entry.source), fontSize = 12.sp, color = if (entry.id == selected) LocalPalette.current.signal else MaterialTheme.colorScheme.onSurfaceVariant)
}
if (entry.removable) SmallIconButton(Icons.Filled.Close, stringResource(R.string.boot_movie_remove), MaterialTheme.colorScheme.error) {
removing = entry
}
}
}
}
}
}
@Composable
private fun RemoveMovieDialog(entry: BootMovieLibrary.Entry, onConfirm: () -> Unit, onDismiss: () -> Unit) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.boot_movie_remove_confirm, entry.title)) },
text = { Text(stringResource(R.string.boot_movie_remove_confirm_body)) },
confirmButton = { androidx.compose.material3.TextButton(onClick = onConfirm) { Text(stringResource(R.string.boot_movie_remove)) } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
)
}
@Composable
private fun ImportTab(s: ModeSettings, a: ModeSettingsActions) {
SettingsRow(
stringResource(R.string.boot_movie_custom),
stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS),
) {
SecondaryButton(
when {
s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress)
s.bootMovieImporting -> stringResource(R.string.common_working)
else -> stringResource(R.string.boot_movie_import)
},
enabled = !s.bootMovieImporting,
progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f },
onClick = a.onImportBootMovie,
)
}
}
@Composable
private fun SoundTab(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) {
val app = LocalContext.current.applicationContext
var volume by remember { mutableIntStateOf(SessionPrefs.bootMovieVolume(app)) }
var seconds by remember { mutableIntStateOf(SessionPrefs.bootMovieSeconds(app)) }
val duration by androidx.compose.runtime.produceState(0, s.bootMovie) {
value = withContext(Dispatchers.IO) { BootMovieLibrary.durationSeconds(BootMovie.currentFile(app)) }
}
ToggleRow(
host, "boot-movie-sound", stringResource(R.string.boot_movie_sound),
stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound,
)
Box(Modifier.padding(horizontal = 6.dp)) {
ValueSlider(
volume, 0..100, 5, s.bootMovieSound, stringResource(R.string.boot_movie_volume), { "$it%" },
) { volume = it; SessionPrefs.setBootMovieVolume(app, it) }
}
Box(Modifier.padding(horizontal = 6.dp)) {
val shownSeconds = if (seconds in 1 until duration) seconds else duration
ValueSlider(
shownSeconds, 1..duration.coerceAtLeast(2), 1, duration > 1, stringResource(R.string.boot_movie_length),
{ value -> app.getString(R.string.boot_movie_seconds, value) },
) { next ->
val stored = if (next >= duration) 0 else next
seconds = stored
SessionPrefs.setBootMovieSeconds(app, stored)
}
}
Text(
stringResource(R.string.boot_movie_length_hint), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp),
)
}
@Composable
private fun RepoTab(a: ModeSettingsActions) {
val progress = installProgress
val app = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
var sort by rememberSaveable { mutableStateOf(SteamDeckRepo.Sort.TRENDING) }
var device by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var page by rememberSaveable { mutableIntStateOf(1) }
var retry by remember { mutableIntStateOf(0) }
var searching by remember { mutableStateOf(false) }
var jumping by remember { mutableStateOf(false) }
var previewing by remember { mutableStateOf<SteamDeckRepo.Post?>(null) }
var installed by remember { mutableStateOf(setOf<String>()) }
val chosen = rememberMovies(0, a.onBootMovieChanged)?.selected
val inUse by androidx.compose.runtime.produceState<String?>(null, chosen) {
value = withContext(Dispatchers.IO) {
chosen?.takeIf { it.startsWith(BootMovieLibrary.PREFIX) }?.let { BootMovieLibrary.repoId(app, it.removePrefix(BootMovieLibrary.PREFIX)) }
}
}
LaunchedEffect(progress.size) { installed = withContext(Dispatchers.IO) { BootMovieLibrary.installedRepoIds(app) } }
val result by androidx.compose.runtime.produceState<SteamDeckRepo.Page?>(null, sort, device, query, page, retry) {
value = null
value = withContext(Dispatchers.IO) { SteamDeckRepo.fetch(sort, device, query, page) }
}
val last by androidx.compose.runtime.produceState<Int?>(null, sort, device, query, retry) {
value = null
value = withContext(Dispatchers.IO) { SteamDeckRepo.lastPage(sort, device, query) }
}
LaunchedEffect(last) { last?.let { if (page > it) page = it } }
val installPost: (SteamDeckRepo.Post) -> Unit = { post ->
if (progress.isEmpty()) {
progress[post.id] = 0
installScope.launch {
val outcome = try {
withContext(Dispatchers.IO) { install(app, post) { progress[post.id] = it } }
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
app.getString(R.string.boot_movie_repo_download_failed)
} finally {
progress.remove(post.id)
}
Toast.makeText(app, outcome ?: app.getString(R.string.boot_movie_repo_installed, post.title), Toast.LENGTH_LONG).show()
a.onBootMovieChanged()
}
}
}
val usePost: (SteamDeckRepo.Post) -> Unit = { post ->
scope.launch {
val name = withContext(Dispatchers.IO) { BootMovieLibrary.nameForRepo(app, post.id) }
if (name != null) a.onBootMovie(BootMovieLibrary.idOf(name))
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(bottom = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
Box(Modifier.weight(1f)) {
ListRow(tall = true, onClick = { searching = true }) {
Icon(Icons.Filled.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(
query.ifEmpty { stringResource(R.string.boot_movie_repo_search) }, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f),
)
}
}
if (query.isNotEmpty()) SecondaryButton(stringResource(R.string.boot_movie_repo_clear), compact = true) { query = ""; page = 1 }
}
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TabChip(stringResource(R.string.boot_movie_repo_all), device == null) { device = null; page = 1 }
TabChip(stringResource(R.string.boot_movie_repo_deck), device == "steam_deck") { device = "steam_deck"; page = 1 }
TabChip(stringResource(R.string.boot_movie_repo_machine), device == "steam_machine") { device = "steam_machine"; page = 1 }
}
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SteamDeckRepo.Sort.values().forEach { option ->
TabChip(
stringResource(
when (option) {
SteamDeckRepo.Sort.TRENDING -> R.string.boot_movie_repo_trending
SteamDeckRepo.Sort.DOWNLOADS -> R.string.boot_movie_repo_downloads
SteamDeckRepo.Sort.LIKES -> R.string.boot_movie_repo_likes
SteamDeckRepo.Sort.NEWEST -> R.string.boot_movie_repo_newest
},
),
sort == option,
) { sort = option; page = 1 }
}
}
when (val r = result) {
null -> Text(stringResource(R.string.common_working), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp))
is SteamDeckRepo.Page.Failed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.boot_movie_repo_failed), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f))
SecondaryButton(stringResource(R.string.boot_movie_repo_retry), compact = true) { retry++ }
}
is SteamDeckRepo.Page.Ok -> {
if (r.posts.isEmpty()) Text(stringResource(R.string.boot_movie_repo_empty), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp))
r.posts.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
row.forEach { post ->
RepoCard(
post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(),
modifier = Modifier.weight(1f), onPreview = { previewing = post }, onInstall = { installPost(post) }, onUse = { usePost(post) },
)
}
if (row.size == 1) Box(Modifier.weight(1f))
}
}
RepoPager(page, last, hasMore = r.posts.size >= SteamDeckRepo.PAGE_SIZE, onPage = { page = it }, onJump = { jumping = true })
}
}
}
if (searching) TextEntryDialog(
stringResource(R.string.boot_movie_repo_search), query, number = false, confirm = stringResource(R.string.stores_search_open),
onSubmit = { value -> if (value != query) { query = value; page = 1 } }, onDismiss = { searching = false },
)
if (jumping) TextEntryDialog(
stringResource(R.string.boot_movie_repo_go_to_page), page.toString(), number = true, confirm = stringResource(R.string.common_ok),
onSubmit = { value -> value.toIntOrNull()?.let { n -> page = n.coerceIn(1, maxOf(1, last ?: n)) } }, onDismiss = { jumping = false },
)
previewing?.let { post ->
PreviewDialog(
post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(),
onInstall = { installPost(post) }, onUse = { usePost(post) }, onDismiss = { previewing = null },
)
}
}
/** Downloads [post] and puts it among the installed movies; returns a message when it failed, null when it worked. */
private fun install(app: android.content.Context, post: SteamDeckRepo.Post, onProgress: (Int) -> Unit): String? {
val folder = File(app.cacheDir, "boot-movie-download").apply { mkdirs() }
// Leftovers of a download that was cut short are cleared, and every download has a file of its own.
folder.listFiles()?.forEach { if (System.currentTimeMillis() - it.lastModified() > 60 * 60 * 1000L) it.delete() }
val file = File.createTempFile("movie-", ".webm", folder)
return try {
if (!SteamDeckRepo.download(post, file, onProgress)) return app.getString(R.string.boot_movie_repo_download_failed)
val cover = if (post.thumbnail.isNotEmpty()) SteamDeckRepo.bytes(post.thumbnail) else null
when (BootMovie.import(app, file, onProgress = onProgress, title = post.title, source = BootMovieLibrary.SOURCE_REPO, repoId = post.id, cover = cover, use = false)) {
is BootMovie.Import.Done -> null
BootMovie.Import.TooLong -> app.getString(R.string.boot_movie_too_long, BootMovie.MAX_CONVERT_SECONDS)
is BootMovie.Import.Unsupported -> app.getString(R.string.boot_movie_unsupported)
BootMovie.Import.Failed -> app.getString(R.string.boot_movie_import_failed)
}
} finally {
file.delete()
}
}
@Composable
private fun RepoCard(
post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier,
onPreview: () -> Unit, onInstall: () -> Unit, onUse: () -> Unit,
) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val shape = RoundedCornerShape(10.dp)
val thumbShape = RoundedCornerShape(7.dp)
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val previewLabel = stringResource(R.string.boot_movie_repo_preview)
Column(
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = modifier.clip(shape)
.background(if (inUse) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f))
.border(if (inUse) 2.dp else 1.dp, if (inUse) pal.signal else pal.line, shape).padding(6.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(thumbShape).background(Color(0xFF0E1218))
.glideBorder(hot, thumbShape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onPreview)
.controllerConfirm(onClick = onPreview)
.semantics { contentDescription = previewLabel },
) {
if (post.thumbnail.isNotEmpty()) AsyncImage(model = post.thumbnail, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
}
}
Text(post.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(repoInfo(post), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse)
}
}
@Composable
private fun repoInfo(post: SteamDeckRepo.Post): String =
listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "")
.filter { it.isNotEmpty() }.joinToString(" · ")
@Composable
private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) {
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse)
else SecondaryButton(
if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install),
enabled = !busy, compact = true, modifier = modifier, onClick = onInstall,
)
}
/** A movie from SteamDeckRepo played straight from the site, so it can be checked before it is installed. */
@Composable
private fun PreviewDialog(
post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean,
onInstall: () -> Unit, onUse: () -> Unit, onDismiss: () -> Unit,
) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val action = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } }
val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp
AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) {
DialogTitleRow(post.title, close)
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f))
}
Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse)
}
}
@Composable
private fun MoviePreview(url: String, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val shape = RoundedCornerShape(10.dp)
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
var state by remember(url) { mutableIntStateOf(0) }
var ratio by remember(url) { mutableFloatStateOf(16f / 9f) }
val player = remember(url) { arrayOfNulls<MediaPlayer>(1) }
DisposableEffect(url) {
onDispose {
runCatching { player[0]?.release() }
player[0] = null
}
}
val toggle = { runCatching { player[0]?.let { if (it.isPlaying) it.pause() else it.start() } }; Unit }
Box(
contentAlignment = Alignment.Center,
modifier = modifier.clip(shape).background(Color.Black)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle),
) {
AndroidView(
modifier = Modifier.aspectRatio(ratio),
factory = { viewContext ->
TextureView(viewContext).apply {
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
val mp = MediaPlayer()
player[0] = mp
try {
mp.setDataSource(viewContext, Uri.parse(url), mapOf("User-Agent" to "DroidDeck"))
mp.setSurface(Surface(texture))
mp.isLooping = true
mp.setOnVideoSizeChangedListener { _, w, h -> if (w > 0 && h > 0) ratio = w.toFloat() / h }
mp.setOnPreparedListener { it.start(); state = 1 }
mp.setOnErrorListener { _, _, _ -> state = 2; true }
mp.prepareAsync()
} catch (e: Exception) {
state = 2
}
}
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) = Unit
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean {
runCatching { player[0]?.release() }
player[0] = null
return true
}
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) = Unit
}
}
},
)
if (state == 0) Text(stringResource(R.string.common_working), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f))
if (state == 2) Text(stringResource(R.string.boot_movie_repo_preview_failed), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.padding(12.dp))
}
}
/** A dialog with one text box, for a search term or a page number; the Android keyboard comes up with it. */
@Composable
private fun TextEntryDialog(
title: String, initial: String, number: Boolean, confirm: String, onSubmit: (String) -> Unit, onDismiss: () -> Unit,
) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var text by remember { mutableStateOf(initial) }
val submit = { onSubmit(text.trim()); close() }
AppDialog(shown, close, "boot-movie-entry", wide = false, maxWidth = 460.dp) {
DialogTitleRow(title, close)
AdbTextField(
value = text, onValueChange = { text = it }, label = "", keyboardType = if (number) KeyboardType.Number else KeyboardType.Text,
imeAction = ImeAction.Done, placeholder = title, focusOnShow = true, onDone = { submit() },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton(stringResource(R.string.common_cancel), modifier = Modifier.weight(1f), onClick = close)
SecondaryButton(confirm, modifier = Modifier.weight(1f)) { submit() }
}
}
}
/** Page buttons around the current page, which opens a box to type another page into; first and last page show their numbers. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RepoPager(page: Int, last: Int?, hasMore: Boolean, onPage: (Int) -> Unit, onJump: () -> Unit) {
val reach = last ?: if (hasMore) Int.MAX_VALUE else page
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
SecondaryButton(stringResource(R.string.boot_movie_repo_previous), enabled = page > 1, compact = true) { onPage(page - 1) }
if (page - 3 > 1) SecondaryButton("1", compact = true) { onPage(1) }
for (n in maxOf(1, page - 3) until page) SecondaryButton("$n", compact = true) { onPage(n) }
TabChip("$page", true, Modifier.widthIn(min = 52.dp).heightIn(min = 44.dp), onJump)
for (n in page + 1..minOf(page + 3, reach)) SecondaryButton("$n", compact = true) { onPage(n) }
if (last != null && page + 3 < last) SecondaryButton("$last", compact = true) { onPage(last) }
SecondaryButton(stringResource(R.string.boot_movie_repo_next), enabled = page < reach, compact = true) { onPage(page + 1) }
}
}
@@ -151,6 +151,7 @@ fun ComponentsPage(
onImport: () -> Unit,
onBack: () -> Unit,
games: List<Library.SteamGame> = emptyList(),
initialGameKey: String? = null,
generalFexPreset: String = "",
onGeneralFexPreset: (String) -> Unit = {},
requestInitialFocus: Boolean = true,
@@ -169,7 +170,7 @@ fun ComponentsPage(
val layer = if (comp in LAYERS) comp else GPU_TAB
BackHandler(onBack = onBack)
var scopeMenu by remember { mutableStateOf(false) }
var selectedGameKey by remember { mutableStateOf<String?>(null) }
var selectedGameKey by remember(initialGameKey) { mutableStateOf(initialGameKey) }
var fexMenu by remember { mutableStateOf(false) }
var gameProtonMenu by remember { mutableStateOf(false) }
var gameProtonId by remember { mutableStateOf<String?>(null) }
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Row
@@ -30,6 +31,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.shape.CircleShape
@@ -110,7 +112,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) }
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp), corner = { GameRemoveButton(current, s, a) }) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
GameActions(current, s, a, onEdit, launch)
}
@@ -132,7 +134,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState())
.padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp),
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp), corner = { GameRemoveButton(current, s, a) }) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a, onEdit, launch)
}
@@ -439,6 +441,47 @@ private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) {
}
}
@Composable
private fun GameRemoveButton(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
val kind = com.droiddeck.launcher.frontend.GameRemoval.kind(g)
if (s.shortcutPicker || kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER) return
if (g.gameFiles?.isDirectory != true) return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
var asking by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
val shortcut = kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT
val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall)
val title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name)
val body = when {
shortcut -> stringResource(R.string.games_remove_body)
g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body)
else -> stringResource(R.string.games_uninstall_body)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("game:remove").downToFirstTile().size(44.dp).clip(Shape12)
.background(if (hot) colors.error.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.34f))
.glideBorder(hot, Shape12, colors.error, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button) { asking = true }
.controllerConfirm { asking = true }
.semantics { contentDescription = label },
) {
androidx.compose.material3.Icon(
Icons.Outlined.Delete, contentDescription = null,
tint = if (hot) colors.error else Color.White, modifier = Modifier.size(18.dp),
)
}
if (asking) ConfirmDialog(
title = title,
text = body,
confirm = label,
onConfirm = { a.onGameRemoved(g) },
onDismiss = { asking = false },
)
}
@Composable
private fun EditGameButton(onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -570,7 +613,7 @@ private fun libraryLabel(g: Library.SteamGame): String = when {
}
@Composable
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composable BoxScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER }
@@ -593,5 +636,6 @@ private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composa
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
content = content,
)
Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner)
}
}
@@ -165,6 +165,7 @@ class FrontEndActions(
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
val onSteamGameArtChanged: (appId: Int) -> Unit = {},
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
val onGameRemoved: (Library.SteamGame) -> Unit = {},
val onAddedGamesArt: (Boolean) -> Unit = {},
val onSgdbKey: (String) -> Unit = {},
val onStopGameFileSync: () -> Unit = {},
@@ -188,6 +189,7 @@ class FrontEndActions(
val onImportLossless: () -> Unit,
val onProtons: () -> Unit,
val onComponents: (focusContent: Boolean) -> Unit,
val onGameComponents: (Library.SteamGame) -> Unit = { onComponents(true) },
val onComponentsTab: (String) -> Unit = {},
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
@@ -523,8 +523,9 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
val back = stringResource(R.string.hop_from_games)
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
val at = Hops.sources["card:components"]
if (at == null) a.onComponents(true)
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
val open = { if (game == null) a.onComponents(true) else a.onGameComponents(game) }
if (at == null) open()
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open)
}
}
add { m ->
@@ -59,16 +59,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.util.Locale
internal val Shape14 = RoundedCornerShape(14.dp)
@Composable
internal fun headingUppercase(text: String): String =
text.uppercase(LocalConfiguration.current.locales[0] ?: Locale.ROOT)
@Composable
internal fun Eyebrow(t: String) {
val pal = LocalPalette.current
@@ -76,7 +82,7 @@ internal fun Eyebrow(t: String) {
LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal))
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
}
}
@@ -88,7 +94,7 @@ internal fun Eyebrow(t: String) {
internal fun SectionTitle(t: String, detail: String?) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) {
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground)
Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
}
@@ -179,10 +185,9 @@ internal fun PrimaryButton(
.graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.clip(Shape12)
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
.shine(hot, 0.45f)
.then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12))
.glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
.then(if (hot) Modifier.border(2.dp, pal.signal, Shape12) else Modifier)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled) {
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
onClick()
}
@@ -143,15 +143,15 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on
}
}
otherId?.let { id ->
val valid = id.isNotEmpty() && GameEnvironment.validScope(id)
val open = { if (valid) { scope = id.toLong().toString(); otherId = null } }
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AdbTextField(
id, { otherId = it.filter(Char::isDigit) }, stringResource(R.string.game_env_app_id), KeyboardType.Number, ImeAction.Done,
modifier = Modifier.weight(1f), compact = true,
onDone = { if (GameEnvironment.validScope(id)) { scope = id.toLong().toString(); otherId = null } },
onDone = open,
)
SecondaryButton(stringResource(R.string.game_env_open), enabled = id.isNotEmpty() && GameEnvironment.validScope(id)) {
scope = id.toLong().toString(); otherId = null
}
SecondaryButton(stringResource(R.string.game_env_open), enabled = valid, onClick = open)
}
}
if (error) Small(stringResource(R.string.game_env_error), error = true)
@@ -0,0 +1,128 @@
package com.droiddeck.launcher.ui
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import java.text.Collator
internal enum class GestureActionGroup(@StringRes val title: Int) {
MOUSE(R.string.gesture_group_mouse), APP(R.string.gesture_group_app),
NAVIGATION(R.string.gesture_group_navigation), LETTERS(R.string.gesture_group_letters),
NUMBERS(R.string.gesture_group_numbers), FUNCTION(R.string.gesture_group_function),
MODIFIERS(R.string.gesture_group_modifiers), NUMPAD(R.string.gesture_group_numpad),
SYMBOLS(R.string.gesture_group_symbols),
}
internal data class GestureAction(val id: String, val title: String, val group: GestureActionGroup)
internal fun gestureActionGroup(id: String): GestureActionGroup {
if (id == "show_keyboard") return GestureActionGroup.APP
if (id in setOf("left_click", "right_click", "middle_click", "left_click_drag", "right_click_drag",
"middle_mouse_pan", "inverted_middle_mouse_pan", "scroll_wheel")) return GestureActionGroup.MOUSE
val key = id.removePrefix("key_")
return when {
key.startsWith("KP_") -> GestureActionGroup.NUMPAD
key.length == 1 && key[0] in 'A'..'Z' -> GestureActionGroup.LETTERS
key.length == 1 && key[0].isDigit() -> GestureActionGroup.NUMBERS
key.matches(Regex("F(?:[1-9]|1[0-2])")) -> GestureActionGroup.FUNCTION
key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_") || key.endsWith("_LOCK") -> GestureActionGroup.MODIFIERS
key in setOf("MINUS", "EQUAL", "BRACKET_LEFT", "BRACKET_RIGHT", "SEMICOLON", "APOSTROPHE",
"GRAVE", "TILDE", "BACKSLASH", "COMMA", "PERIOD", "SLASH") || id == "plus_minus" -> GestureActionGroup.SYMBOLS
else -> GestureActionGroup.NAVIGATION
}
}
internal fun filterGestureActions(actions: List<GestureAction>, query: String, group: GestureActionGroup?): List<GestureAction> {
val words = query.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
val collator = Collator.getInstance()
return actions.filter { action ->
(group == null || action.group == group) && words.all { word ->
action.title.contains(word, ignoreCase = true) || action.id.removePrefix("key_").replace('_', ' ').contains(word, ignoreCase = true)
}
}.sortedWith(compareBy<GestureAction> { it.group.ordinal }.thenComparator { a, b ->
// Function keys follow their numeric sequence; names use the UI locale.
if (a.group == GestureActionGroup.FUNCTION) {
a.id.removePrefix("key_F").toInt().compareTo(b.id.removePrefix("key_F").toInt())
} else collator.compare(a.title, b.title)
}.thenBy { it.id })
}
@Composable
internal fun gestureActionTitle(action: String): String {
val named = when (action) {
"left_click" -> R.string.gesture_left_click
"right_click" -> R.string.gesture_right_click
"middle_click" -> R.string.gesture_middle_click
"show_keyboard" -> R.string.gesture_keyboard
"left_click_drag" -> R.string.gesture_left_drag
"right_click_drag" -> R.string.gesture_right_drag
"middle_mouse_pan" -> R.string.gesture_middle_pan
"inverted_middle_mouse_pan" -> R.string.gesture_middle_pan_inverted
"arrow_keys" -> R.string.gesture_arrows
"inverted_arrow_keys" -> R.string.gesture_arrows_inverted
"inverted_wasd" -> R.string.gesture_wasd_inverted
"scroll_wheel" -> R.string.gesture_scroll
else -> null
}
if (named != null) return stringResource(named)
return when (action) {
"wasd" -> "WASD"
"plus_minus" -> "+ / −"
"page_up_down" -> stringResource(R.string.gesture_key_page_up) + " / " + stringResource(R.string.gesture_key_page_down)
else -> gestureKeyTitle(action.removePrefix("key_"))
}
}
@Composable
private fun gestureKeyTitle(key: String): String {
if (key.startsWith("KP_")) return stringResource(R.string.gesture_key_numpad, gestureKeyTitle(key.removePrefix("KP_")))
val named = when (key) {
"ESC" -> R.string.gesture_key_escape
"BKSP" -> R.string.gesture_key_backspace
"ENTER" -> R.string.gesture_key_enter
"TAB" -> R.string.gesture_key_tab
"SPACE" -> R.string.gesture_key_space
"DEL" -> R.string.gesture_key_delete
"INSERT" -> R.string.gesture_key_insert
"HOME" -> R.string.gesture_key_home
"END" -> R.string.gesture_key_end
"PRIOR" -> R.string.gesture_key_page_up
"NEXT" -> R.string.gesture_key_page_down
"PRTSCN" -> R.string.gesture_key_print_screen
"UP" -> R.string.gesture_key_up
"DOWN" -> R.string.gesture_key_down
"LEFT" -> R.string.gesture_key_left_arrow
"RIGHT" -> R.string.gesture_key_right_arrow
"CAPS_LOCK" -> R.string.gesture_key_caps_lock
"NUM_LOCK" -> R.string.gesture_key_num_lock
"SCROLL_LOCK" -> R.string.gesture_key_scroll_lock
else -> null
}
if (named != null) return stringResource(named)
if (key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_")) {
val name = stringResource(when (key.substringBefore('_')) {
"CTRL" -> R.string.pckb_key_ctrl
"SHIFT" -> R.string.gesture_key_shift
else -> R.string.pckb_key_alt
})
return stringResource(if (key.endsWith("_L")) R.string.gesture_key_left else R.string.gesture_key_right, name)
}
return when (key) {
"MINUS", "SUBTRACT" -> "−"
"EQUAL" -> "="
"ADD" -> "+"
"MULTIPLY" -> "×"
"DIVIDE", "SLASH" -> "/"
"BRACKET_LEFT" -> "["
"BRACKET_RIGHT" -> "]"
"SEMICOLON" -> ";"
"APOSTROPHE" -> "'"
"GRAVE" -> "`"
"TILDE" -> "~"
"BACKSLASH" -> "\\"
"COMMA" -> ","
"PERIOD" -> "."
else -> key.replace('_', ' ')
}
}
@@ -0,0 +1,35 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.input.TouchscreenGestures.Gesture
@Composable
fun GestureFeedback(gesture: Gesture?) {
Box(Modifier.fillMaxSize()) {
if (gesture != null) Text(stringResource(when (gesture) {
Gesture.TAP -> R.string.gesture_tap
Gesture.DRAG -> R.string.gesture_drag
Gesture.LONG_PRESS -> R.string.gesture_long_press
Gesture.DOUBLE_TAP -> R.string.gesture_double_tap
Gesture.TWO_FINGER_TAP -> R.string.gesture_two_tap
Gesture.TWO_FINGER_DRAG -> R.string.gesture_two_drag
Gesture.TWO_FINGER_HOLD -> R.string.gesture_two_hold
Gesture.PINCH -> R.string.gesture_pinch
Gesture.THREE_FINGER_TAP -> R.string.gesture_three_tap
Gesture.THREE_FINGER_DRAG -> R.string.gesture_three_drag
Gesture.THREE_FINGER_HOLD -> R.string.gesture_three_hold
}),
Modifier.align(Alignment.TopCenter).padding(top = 24.dp)
.background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -1,9 +1,11 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.text.format.Formatter
import android.speech.tts.TextToSpeech
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -70,6 +72,7 @@ import com.droiddeck.launcher.handtracking.Advice
import com.droiddeck.launcher.handtracking.BodyPose
import com.droiddeck.launcher.handtracking.HandCamera
import com.droiddeck.launcher.handtracking.HandTracking
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.handtracking.Landmark
import com.droiddeck.launcher.handtracking.TrackingCameras
import kotlinx.coroutines.delay
@@ -186,11 +189,27 @@ internal fun HandCameraRows(host: MenuHost, track: (String) -> Modifier, inSessi
})
!s.permitted -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_hint),
stringResource(R.string.hand_camera_allow_button), { ask.launch(Manifest.permission.CAMERA) })
!s.downloaded -> TrackingPackRow()
!inSession -> ActionRow(stringResource(R.string.hand_camera_placement), stringResource(R.string.hand_camera_placement_hint),
stringResource(R.string.hand_camera_check), { HandTracking.requestCheck() })
}
}
@Composable
private fun TrackingPackRow() {
val context = LocalContext.current
val pack by TrackingPack.state.collectAsState()
val size = Formatter.formatShortFileSize(context, TrackingPack.size)
when (val now = pack) {
is AddonPack.State.Downloading -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), {}, progress = now.progress)
AddonPack.State.Failed -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_failed), stringResource(R.string.tracking_pack_retry), { TrackingPack.ensure(context) })
else -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), { TrackingPack.ensure(context) })
}
}
@Composable
private fun PlacementCheck(settings: HandTracking.Settings, play: Boolean, onPlay: () -> Unit, onClose: () -> Unit) {
val context = LocalContext.current
@@ -66,6 +66,22 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () ->
)
}
@Composable
fun RuntimeActionsDialog(onRepair: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.runtime_actions_title)) },
text = { Text(stringResource(R.string.runtime_actions_text)) },
confirmButton = { TextButton(onClick = onRepair) { Text(stringResource(R.string.runtime_repair)) } },
dismissButton = {
Row {
TextButton(onClick = onRemove) { Text(stringResource(R.string.store_remove)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
}
},
)
}
@Composable
fun ResetSettingsDialog(onConfirm: (gameProfiles: Boolean) -> Unit, onDismiss: () -> Unit) {
var gameProfiles by remember { mutableStateOf(false) }
@@ -139,7 +139,7 @@ class ModeSettingsActions(
val onBootMovie: (String) -> Unit = {},
val onBootMovieSound: (Boolean) -> Unit = {},
val onImportBootMovie: () -> Unit = {},
val onRemoveBootMovie: () -> Unit = {},
val onBootMovieChanged: () -> Unit = {},
val onWifiDiscovery: (Boolean) -> Unit = {},
val onWifiDiscoverySettings: () -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
@@ -250,7 +250,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
@@ -467,41 +467,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
onPick = a.onSteamClientArch,
)
}
if (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) {
ChoiceRow(
host, "boot-movie", stringResource(R.string.boot_movie_video), stringResource(R.string.common_applies_next_session),
buildList {
add(BootMovie.DROIDDECK_30 to stringResource(R.string.boot_movie_droiddeck, 30))
add(BootMovie.DROIDDECK_60 to stringResource(R.string.boot_movie_droiddeck, 60))
s.bootMovieCustomName?.let { add(BootMovie.CUSTOM to it) }
}, s.bootMovie,
note = stringResource(R.string.boot_movie_note), onPick = a.onBootMovie,
)
ToggleRow(
host, "boot-movie-sound", stringResource(R.string.boot_movie_sound),
stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound,
)
SettingsRow(
stringResource(R.string.boot_movie_custom),
stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS),
) {
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
when {
s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress)
s.bootMovieImporting -> stringResource(R.string.common_working)
else -> stringResource(R.string.boot_movie_import)
},
enabled = !s.bootMovieImporting,
progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f },
onClick = a.onImportBootMovie,
)
if (s.bootMovieCustomName != null) SecondaryButton(
stringResource(R.string.boot_movie_remove), enabled = !s.bootMovieImporting, onClick = a.onRemoveBootMovie,
)
}
}
}
if (s.bootMovie != null) BootMovieGroup(s, a, host)
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
@@ -122,6 +122,7 @@ fun LoadingOverlay(
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
endedDetail: String? = null,
onRetry: (() -> Unit)? = null,
onRepair: (() -> Unit)? = null,
onShareLogs: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
flood: Color? = null,
@@ -187,7 +188,7 @@ fun LoadingOverlay(
textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp),
)
if (ended) EndedActions(onRetry, onShareLogs, onClose)
if (ended) EndedActions(onRetry, onRepair, onShareLogs, onClose)
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
@@ -200,7 +201,7 @@ fun LoadingOverlay(
}
@Composable
private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
private fun EndedActions(onRetry: (() -> Unit)?, onRepair: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
val first = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
@@ -209,7 +210,11 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) {
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
if (onRepair != null) PrimaryButton(stringResource(R.string.runtime_repair), modifier = claim(), onClick = onRepair)
if (onRetry != null) {
if (onRepair != null) SecondaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
else PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
}
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(),
progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs)
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
@@ -245,6 +245,7 @@ class DrawerActions(
val onLowLatencyController: (Boolean) -> Unit = {},
val onLowLatencyTouch: (Boolean) -> Unit = {},
val onTouch: (String) -> Unit,
val onGestureSettings: () -> Unit = {},
val onFexPreset: (String) -> Unit,
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
@@ -500,8 +501,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
}
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint),
stringResource(R.string.ctrl_edit), a.onGestureSettings,
modifier = focus.track(page, "gesture-settings"))
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
@@ -648,8 +648,8 @@ fun MultiRow(
}
@Composable
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) {
SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) }
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null, modifier: Modifier = Modifier) {
SettingsRow(label, hint) { SecondaryButton(button, modifier = modifier, progress = progress, onClick = onClick) }
}
@Composable
@@ -29,13 +29,13 @@ import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.glasses.CalibrationDistance
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.glasses.Glasses
import com.droiddeck.launcher.glasses.SpatialCalibrationProgress
import com.droiddeck.launcher.glasses.SpatialTracking
import com.droiddeck.launcher.glasses.SpatialTrackingStatus
import com.droiddeck.launcher.glasses.StereoCalibrator
import kotlinx.coroutines.delay
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.Mat
import org.opencv.core.Size
@@ -95,7 +95,7 @@ class SpatialTrackingActivity : ComponentActivity() {
}
override fun onPause() { SpatialTracking.diagnostics(false);super.onPause() }
private fun printBoard() {
if(!OpenCVLoader.initLocal()) return
if(!TrackingPack.loadNative(this)) return
val c=StereoCalibrator(this);val mat=Mat()
c.board.generateImage(Size(1500.0,2100.0),mat,0,1)
val bitmap=Bitmap.createBitmap(1500,2100,Bitmap.Config.ARGB_8888)
@@ -781,17 +781,18 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack:
}
@Composable
private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
internal fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val busy = sessionRunning || StorageState.cleaning
var confirm by remember { mutableStateOf<ComponentUsage?>(null) }
var confirmAll by remember { mutableStateOf(false) }
val unused = report.unusedComponents
SubHeader(stringResource(R.string.storage_components),
stringResource(R.string.storage_tools_detail, gb(report.components.sumOf { it.bytes }), gb(unused.sumOf { it.bytes })), onBack) {
if (unused.isNotEmpty()) PrimaryButton(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes })), compact = true, enabled = !busy) {
StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { }
confirmAll = true
}
}
ActionStatus()
@@ -824,6 +825,19 @@ private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBac
stringResource(if (c.packageFile != null) R.string.storage_component_confirm else R.string.storage_original_confirm, gb(c.bytes)),
stringResource(R.string.storage_remove), onConfirm = { StorageState.removeComponent(ctx, c) }, onDismiss = { confirm = null })
}
if (confirmAll) {
val total = gb(unused.sumOf { it.bytes })
val lines = unused.map { "• " + componentLabel(it) + " " + gb(it.bytes) }.joinToString("\n")
val warning = stringResource(
if (unused.any { it.packageFile != null }) R.string.storage_component_confirm else R.string.storage_original_confirm, total)
ConfirmDialog(
stringResource(R.string.storage_remove_unused, total),
lines + "\n\n" + warning,
stringResource(R.string.storage_remove),
onConfirm = { StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { } },
onDismiss = { confirmAll = false },
)
}
}
@Composable
@@ -125,7 +125,8 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
if (store == Store.XBOX) {
SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) {
com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed)
runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) }
.onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." }
}
SecondaryButton("Manage content", enabled = download == null) {
com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed)
@@ -25,6 +25,7 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.geometry.Size
@@ -483,7 +484,7 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) {
}
}
internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<FrontEndState>, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk =
internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<FrontEndState>, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color, onArrange: () -> Unit): StepAsk =
StepAsk(
anchor = at, side = StepSide.Left, accent = accent, items = 2, maxWidth = 440.dp,
handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) },
@@ -494,6 +495,12 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val st = s.value
SettingsRow(stringResource(R.string.stores_tabs_arrange), null) {
RoundAction(Icons.Filled.SwapHoriz, stringResource(R.string.stores_tabs_arrange), size = 36.dp) {
StoresMotion.fold()
onArrange()
}
}
SettingsRow(stringResource(R.string.setup_stores_open_on), null, stacked = true) {
Box(Modifier.focusRequester(first).focusGroup()) { SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
@@ -506,6 +513,9 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) }
val context = androidx.compose.ui.platform.LocalContext.current
var gogAchievements by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(com.droiddeck.launcher.stores.gog.GogComet.enabled(context)) }
androidx.compose.runtime.LaunchedEffect(Unit) {
if (gogAchievements) com.droiddeck.launcher.core.AddonPack.COMET.ensure(context)
}
SettingsRow(stringResource(R.string.gog_achievements), stringResource(R.string.gog_achievements_hint)) {
ToggleSwitch(gogAchievements, label = stringResource(R.string.gog_achievements)) {
gogAchievements = it
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.outlined.Folder
@@ -24,6 +25,7 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
@@ -37,6 +39,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import android.view.KeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.drawWithContent
@@ -65,6 +68,8 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import kotlin.math.roundToInt
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
@@ -116,6 +121,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -137,7 +143,9 @@ import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreTab
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.arrangeStoreTabs
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.formatBytes
@@ -194,12 +202,53 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
val narrow = LocalNarrowPane.current
val padH = if (narrow) 14.dp else 22.dp
var chip by rememberSaveable { mutableStateOf(Store.GOG.id) }
val tabs = remember {
androidx.compose.runtime.mutableStateListOf<StoreTab>().apply {
addAll(arrangeStoreTabs(SessionPrefs.storesTabOrder(ctx), SessionPrefs.storesTabHidden(ctx)))
}
}
var arranging by remember { mutableStateOf(false) }
val shownStores = tabs.filter { it.shown }.map { it.store }
fun commitTabs() {
SessionPrefs.setStoresTabOrder(ctx, tabs.map { it.store.id })
SessionPrefs.setStoresTabHidden(ctx, tabs.filterNot { it.shown }.map { it.store.id }.toSet())
}
fun shiftTab(store: Store, delta: Int) {
val from = tabs.indexOfFirst { it.store == store }
val to = from + delta
if (from < 0 || to !in tabs.indices) return
tabs.add(to, tabs.removeAt(from))
commitTabs()
}
fun moveTab(store: Store, to: Int) {
val from = tabs.indexOfFirst { it.store == store }
if (from < 0) return
val target = to.coerceIn(0, tabs.lastIndex)
if (target == from) return
tabs.add(target, tabs.removeAt(from))
commitTabs()
}
fun toggleTab(store: Store) {
val i = tabs.indexOfFirst { it.store == store }
if (i < 0) return
val show = !tabs[i].shown
if (!show && tabs.count { it.shown } <= 1) return
tabs[i] = tabs[i].copy(shown = show)
commitTabs()
}
LaunchedEffect(tabs.map { it.store to it.shown }, chip) {
val picked = Store.byId(chip) ?: return@LaunchedEffect
if (tabs.none { it.store == picked && it.shown }) chip = tabs.firstOrNull { it.shown }?.store?.id ?: chip
}
var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") }
var query by rememberSaveable { mutableStateOf("") }
var openGame by rememberSaveable { mutableStateOf<String?>(null) }
val liveState = androidx.compose.runtime.rememberUpdatedState(s)
LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } }
val store = Store.byId(chip)
LaunchedEffect(tabs.toList()) {
if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS
}
val shownTab = tabFor(store, tab)
val pane = PaneKey(
chip, if (store == null) "" else shownTab,
@@ -246,7 +295,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
}
val pal = LocalPalette.current
fun cycleStore(step: Int) {
val ids = Store.entries.map { it.id }
val ids = tabs.filter { it.shown }.map { it.store.id }
if (ids.isEmpty()) return
val at = ids.indexOf(chip)
pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size])
}
@@ -254,6 +304,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
if (chip != st.id) pickChip(st.id) else move("first")
}
BackHandler(enabled = openGame != null) { closeGame() }
BackHandler(enabled = arranging) { arranging = false }
LaunchedEffect(arranging) {
val focus = ff ?: return@LaunchedEffect
if (!arranging || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
repeat(45) {
androidx.compose.runtime.withFrameNanos { }
val req = tabs.firstOrNull()?.let { focus.items["storechip:${it.store.id}"] } ?: return@repeat
if (runCatching { req.requestFocus() }.isSuccess) return@LaunchedEffect
}
}
val scroll = rememberScrollState()
val detailScroll = rememberScrollState()
LaunchedEffect(chip, tab) { scroll.scrollTo(0); grid.scrollToItem(0) }
@@ -329,14 +389,32 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
.bumpers(
onPrevious = { if (openGame == null) cycleStore(-1) },
onNext = { if (openGame == null) cycleStore(1) },
onPrevious = {
val picked = Store.byId(focusedChip)
if (arranging) { if (picked != null) shiftTab(picked, -1) }
else if (openGame == null) cycleStore(-1)
},
onNext = {
val picked = Store.byId(focusedChip)
if (arranging) { if (picked != null) shiftTab(picked, 1) }
else if (openGame == null) cycleStore(1)
},
),
) {
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
StoreTheme(Store.byId(focusedChip ?: chip)) {
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
Rise(0) {
StoreChips(
chip, focusedChip, s.storeDownloadsActive, tabs, arranging,
onStore = { st -> openModes(st) },
onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
onFocusChip = { focusedChip = it },
onArrangeDone = { arranging = false },
onMoveTab = { st, to -> moveTab(st, to) },
onToggleTab = { toggleTab(it) },
onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal) { arranging = true }) },
)
}
}
}
val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() }
@@ -547,55 +625,148 @@ private fun SignInFloodLayer(hostAt: Offset) {
}
private class ChipDrag {
var store by mutableStateOf<Store?>(null)
var from by mutableIntStateOf(-1)
var target by mutableIntStateOf(-1)
var x by mutableFloatStateOf(0f)
}
@Composable
private fun StoreDot(store: Store, signedIn: Boolean) {
val c = sourceColours(store.id)
Box(
Modifier.size(12.dp)
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
}
@Composable
private fun RowScope.ArrangeStoreChip(
tab: StoreTab, index: Int, last: Int, gapPx: Float, drag: ChipDrag,
onFocusChip: (String?) -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit,
) {
val store = tab.store
val box = remember(store) { mutableFloatStateOf(0f) }
val pitch = box.floatValue + gapPx
val held = drag.store == store
val lift by animateFloatAsState(if (held) 1f else 0f, Motion.sp(0.7f, Spring.StiffnessMediumLow), label = "chipLift")
val shift by animateFloatAsState(
when {
drag.store == null || held -> 0f
drag.from < index && drag.target >= index -> -pitch
drag.from > index && drag.target <= index -> pitch
else -> 0f
},
Motion.sp(0.6f, Spring.StiffnessMediumLow), label = "chipShift",
)
val pill = remember { RoundedCornerShape(99.dp) }
val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true
fun reset() {
drag.store = null
drag.from = -1
drag.target = -1
drag.x = 0f
}
fun commit() {
val from = drag.from
val to = drag.target
reset()
if (from >= 0 && to >= 0 && to != from) onMoveTab(store, to)
}
StoreChip(
label = store.shortLabel, on = false, under = false, key = store.id, mark = sourceColours(store.id).dot, lift = lift,
modifier = Modifier.weight(1f)
.zIndex(if (held) 2f else 0f)
.onSizeChanged { box.floatValue = it.width.toFloat() }
.graphicsLayer {
translationX = if (held) drag.x else shift
shadowElevation = 14.dp.toPx() * lift
shape = pill
clip = false
}
.pointerInput(store, index) {
detectDragGestures(
onDragStart = {
drag.store = store
drag.from = index
drag.target = index
drag.x = 0f
},
onDragEnd = { commit() },
onDragCancel = { reset() },
) { change, amount ->
change.consume()
val step = box.floatValue + gapPx
if (step <= 0f) return@detectDragGestures
drag.x = (drag.x + amount.x).coerceIn(-drag.from * step, (last - drag.from) * step)
drag.target = (drag.from + (drag.x / step).roundToInt()).coerceIn(0, last)
}
}
.alpha(if (tab.shown) 1f else 0.32f),
lead = { StoreDot(store, signedIn) },
onFocused = onFocusChip,
) { onToggleTab(store) }
}
@Composable
private fun StoreChips(
selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit,
onFocusChip: (String?) -> Unit,
selected: String, focused: String?, active: Int, tabs: List<StoreTab>, arranging: Boolean,
onStore: (Store) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, onFocusChip: (String?) -> Unit,
onArrangeDone: () -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit,
) {
val pal = LocalPalette.current
val keys = Store.entries.map { it.id } + DOWNLOADS
val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, Rect>() }
val rootBounds = remember { HashMap<String, Rect>() }
val highlighted = focused ?: selected
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode
val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard
val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging
val tint = androidx.compose.animation.animateColorAsState(
Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: pal.signal, Motion.tw(160), label = "chipTint",
)
val gap = 8.dp
val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() }
val drag = remember { ChipDrag() }
Box(Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (store in Store.entries) {
val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true
StoreChip(
label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot,
modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() },
lead = {
val c = sourceColours(store.id)
Box(
Modifier.size(12.dp)
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
)
},
description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out),
onFocused = onFocusChip,
) { onStore(store, rootBounds[store.id]) }
Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (tab in tabs) {
val store = tab.store
if (arranging) {
val index = tabs.indexOfFirst { it.store == store }
key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) }
} else if (tab.shown) {
val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true
key(store) {
StoreChip(
label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot,
modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() },
lead = { StoreDot(store, signedIn) },
description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out),
onFocused = onFocusChip,
) { onStore(store) }
}
}
}
StoreChip(
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
modifier = Modifier.onGloballyPositioned {
bounds[DOWNLOADS] = it.boundsInParent()
StoresMotion.downloadsChip = it.boundsInRoot()
},
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
trail = if (active > 0) { { CountPill(active) } } else null,
description = stringResource(R.string.stores_tab_downloads),
onFocused = onFocusChip,
) { onDownloads() }
var cogAt by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) {
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) }
if (arranging) {
IconChip(Icons.Filled.Check, stringResource(R.string.stores_tabs_arrange_done)) { onArrangeDone() }
} else {
StoreChip(
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
modifier = Modifier.onGloballyPositioned {
bounds[DOWNLOADS] = it.boundsInParent()
StoresMotion.downloadsChip = it.boundsInRoot()
},
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
trail = if (active > 0) { { CountPill(active) } } else null,
description = stringResource(R.string.stores_tab_downloads),
onFocused = onFocusChip,
) { onDownloads() }
var cogAt by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) {
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) }
}
}
}
ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown)
@@ -605,7 +776,7 @@ private fun StoreChips(
@Composable
private fun StoreChip(
label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
trail: (@Composable () -> Unit)? = null, description: String? = null,
trail: (@Composable () -> Unit)? = null, description: String? = null, lift: Float = 0f,
onFocused: (String?) -> Unit = {}, onClick: () -> Unit,
) {
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
@@ -630,10 +801,10 @@ private fun StoreChip(
if (f.isFocused && inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) onFocused(key)
else if (!f.isFocused) onFocused(null)
}
.heightIn(min = 44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(shape)
.background(if (under) Color.Transparent else colors.surface)
.heightIn(min = 44.dp).graphicsLayer { scaleX = scale * (1f + 0.08f * lift); scaleY = scale * (1f + 0.08f * lift) }.clip(shape)
.background(if (under) Color.Transparent else lerp(colors.surface, pal.signal, 0.18f * lift))
.shownBar(on, mark)
.glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line)
.glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else lerp(pal.line, pal.signal, lift))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier)
@@ -961,7 +1132,7 @@ private suspend fun <T> CompletableFuture<T>.await(): T =
@Composable
private fun SectionLabel(title: String) {
Text(
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp),
)
}
@@ -996,6 +1167,7 @@ internal fun ModeHeader(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var searching by remember { mutableStateOf(query.isNotEmpty()) }
var focusOnOpen by remember { mutableStateOf(false) }
val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen")
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) {
@@ -1010,11 +1182,11 @@ internal fun ModeHeader(
Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) {
AdbTextField(
value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search,
placeholder = placeholder, compact = true, focusOnShow = true,
onFocusChange = { focused -> if (!focused && query.isEmpty()) searching = false },
placeholder = placeholder, compact = true, focusOnShow = focusOnOpen,
onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } },
)
}
} else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true }
} else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true; focusOnOpen = true }
}
trailing()
}
@@ -56,7 +56,7 @@ object Themes {
ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail,
background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E),
onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8),
primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF),
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
good = Color(0xFF4CD37F),
),
)
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more