Merge main into feat/desktop-pacman

This commit is contained in:
maxjivi05 committed 2026-10-30 14:02:17 -04:00
commit 801e434345
621 files changed
+44495 -2118

No files matched your search

@@ -0,0 +1,95 @@
name: Build Linux runtime (Ubuntu)
on:
push:
paths:
- 'tools/ubuntu/**'
- '.github/workflows/build-ubuntu-runtime.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish as (e.g. ubuntu-r1); empty = artifact only'
required: false
default: ''
permissions:
contents: read
jobs:
debs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build Mesa with KGSL and the patched KWin
run: |
set -euo pipefail
docker run --rm -v "$PWD:/work" -w /work ubuntu:26.04 bash tools/ubuntu/build-debs.sh /work/out/debs
sudo chown -R "$(id -u):$(id -g)" out
- uses: actions/upload-artifact@v4
with:
name: ubuntu-runtime-debs
retention-days: 7
compression-level: 0
path: out/debs/
rootfs:
needs: debs
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: ubuntu-runtime-debs
path: out/debs
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Refuse a published tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
run: |
if gh release view '${{ inputs.tag }}' -R Droid-Deck/DroidDeck-Components >/dev/null 2>&1; then
echo "::error::${{ inputs.tag }} is already published, and published tags are never replaced: use a new tag"
exit 1
fi
- name: Build
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y -qq zstd gpgv ubuntu-keyring >/dev/null
tag='${{ inputs.tag }}'
tools/ubuntu/build.sh out/debs out "${tag:-ubuntu-dev}"
- uses: actions/upload-artifact@v4
with:
name: ubuntu-runtime
retention-days: 7
compression-level: 0
path: |
out/*.tar.zst
out/*.sha256
out/*.packages.txt
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
GH_REPO: Droid-Deck/DroidDeck-Components
run: |
set -euo pipefail
TAG='${{ inputs.tag }}'
prerelease=false
case $TAG in *-rc*) prerelease=true ;; esac
gh release create "$TAG" --latest=false --prerelease="$prerelease" --title "$TAG" \
--notes "Ubuntu 26.04 LTS arm64 runtime for DroidDeck ($GITHUB_REPOSITORY tools/ubuntu, $GITHUB_SHA): size $(stat -c %s "out/$TAG.tar.zst"), sha256 $(cat "out/$TAG.sha256"). $TAG-desktop.tar.zst is the Kubuntu Plasma desktop built on top of it, holding only what the runtime does not already have: size $(stat -c %s "out/$TAG-desktop.tar.zst"), sha256 $(cat "out/$TAG-desktop.sha256"). The .packages.txt files list every installed package and version." \
"out/$TAG.tar.zst" "out/$TAG.sha256" "out/$TAG.packages.txt" \
"out/$TAG-desktop.tar.zst" "out/$TAG-desktop.sha256" "out/$TAG-desktop.packages.txt" out/debs/*.deb
gh release edit "$TAG" --latest=false --prerelease="$prerelease"
+85 -11
View File
@@ -8,6 +8,8 @@ on:
- '**/*.md'
- 'LICENSE'
- '.gitignore'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
pull_request:
branches: [ main ]
types: [ opened, synchronize, reopened, ready_for_review ]
@@ -15,6 +17,8 @@ on:
- '**/*.md'
- 'LICENSE'
- '.gitignore'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
workflow_dispatch:
inputs:
rebuild_preloads:
@@ -28,8 +32,8 @@ permissions:
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: build-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
jobs:
source:
@@ -45,6 +49,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Pin and merge the APK source
id: merge
@@ -78,6 +86,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
@@ -132,6 +144,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
@@ -283,6 +299,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check human authorship
@@ -404,7 +424,7 @@ jobs:
- uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
cache-cleanup: on-success
cache-cleanup: ${{ github.event_name == 'pull_request' && 'never' || 'on-success' }}
- name: Resolve the pinned Android NDK
run: bash tools/ci/resolve-ndk.sh
@@ -526,13 +546,6 @@ jobs:
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
- name: Store engines host unit tests
timeout-minutes: 10
continue-on-error: true
run: |
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
- name: Upload the regenerated Cargo.lock
if: failure()
uses: actions/upload-artifact@v4
@@ -622,6 +635,63 @@ jobs:
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
run: bash tools/release/report-build.sh
store-tests:
name: Store engines host tests
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 15
continue-on-error: true
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -euo pipefail
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match
env:
XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- name: Rust toolchain
id: rust
run: echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache cargo registry and the host test target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
app/src/main/rust/stores/target
key: stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs') }}
restore-keys: |
stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-
stores-host-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.rust.outputs.rustc }}-
- name: Store engines host unit tests
timeout-minutes: 10
continue-on-error: true
run: |
set -o pipefail
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
python-tests:
name: Python and native regression tests
needs: source
@@ -633,6 +703,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
sparse-checkout: |
/*
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
@@ -681,7 +755,7 @@ jobs:
timeout 10 /tmp/test-presenter-shutdown
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
run: bash tools/ci/run-python-tests.sh
checks:
name: Compile and test APK
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
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
for name in proot directaudio tracking xbox comet butler; 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)
+3 -3
View File
@@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Pluvia is an unofficial Steam client app for Android
Copyright (C) 2024 Oxters Wyzgowski
DroidDeck brings the SteamOS experience to Android
Copyright (C) 2026 DroidDeck contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Pluvia Copyright (C) 2024 Oxters Wyzgowski
DroidDeck Copyright (C) 2026 DroidDeck contributors
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
+9 -1
View File
@@ -33,6 +33,14 @@ Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adre
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
## Physical button shortcuts
Open the launcher's **Controls** settings, **Setup → Controller → Button mapping**, or the session drawer's **Controller** page. Each menu action has one row showing **Custom** or **Default**. Select the current value beside **Steam menu**, **Open Quick Access Menu**, or **DroidDeck menu**, then press and release a physical button. The assignment saves automatically on release; confirmation is only required if the button already opens another menu. Assignments are stored per controller; each action has one assigned button per controller. A mapped button performs the shortcut instead of sending its normal input to the game. Steam shortcuts only apply in Steam sessions, and shortcuts are inactive while loading, suspended, or using the on-screen keyboard or other input dialogs.
Unassigned buttons keep their existing behavior, including the Back button's single/double-press setting and Select-to-Steam option. While the session drawer is open, A selects the focused item even if it has a shortcut assignment. Select an action's current value and use **Reset** beside an assignment to restore that behavior without changing touch layouts or other controller settings. **Close** exits capture without changing an assignment; it does not undo an assignment already applied. The touchscreen remains available for configuration and recovery.
Extra buttons such as M1/M2 work only if Android exposes distinct key events to the app. Android Home, volume and other reserved system keys cannot be assigned; making DroidDeck the default launcher retains its existing Home-to-Steam behavior. Firmware that intercepts a button or makes it duplicate another button cannot be overridden here. During capture, the detected device and button name are shown. If a controller disconnects before release, use **Try another button** to restart capture; **Reset** remains available for existing assignments. Capture and shortcut-action diagnostics, including key and scan codes, are included in session logs when capture runs inside a session. If a button is not detected, record its physical label, firmware/controller mode, and whether it leaves the app, then share the session logs.
## Smart glasses
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
@@ -61,7 +69,7 @@ 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. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. Its games install from Stores through Blizzard's own Agent and the local HTTP API it serves, driven the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) drives it, with the requests described on [wowdev.wiki](https://wowdev.wiki/Agent); how other launchers find, install and start Blizzard games was read in [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader, without taking their code. 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. x86 AppImages packed with [sharun](https://github.com/VHSgunzo/sharun) by VHSgunzo (MIT) start because DroidDeck's own [FEX](https://github.com/FEX-Emu/FEX) build (MIT) puts the platform name under `AT_PLATFORM`, which sharun's [userland-execve](https://crates.io/crates/userland-execve) loader (MIT) requires; FEX filed it under `AT_BASE_PLATFORM`. 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. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultLine truncated
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. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. Its games install from Stores through Blizzard's own Agent and the local HTTP API it serves, driven the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) drives it, with the requests described on [wowdev.wiki](https://wowdev.wiki/Agent); how other launchers find, install and start Blizzard games was read in [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader, without taking their code. 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. x86 AppImages packed with [sharun](https://github.com/VHSgunzo/sharun) by VHSgunzo (MIT) start because DroidDeck's own [FEX](https://github.com/FEX-Emu/FEX) build (MIT) puts the platform name under `AT_PLATFORM`, which sharun's [userland-execve](https://crates.io/crates/userland-execve) loader (MIT) requires; FEX filed it under `AT_BASE_PLATFORM`. 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. The in-game performance HUD is [@The412Banner](https://github.com/The412Banner)'s [FusionHUD](https://github.com/The412Banner/FusionHUD) (GPL-3.0 with an attribution term), Bannerlator's HUD; its frame rate, frame times and readings are taken from the same sources, and worked out the same way, as Valve's [MangoHud](https://github.com/flightlessmango/MangoHud) build (MIT) that runs Steam's performance overlay in a DroidDeck session, with the frame times shared by gamescope patch 0125; when gamescope sends no frames for two seconds, the HUD counts the frames DroidDeck's compositor shows instead, which stop at the screen's refresh rate. 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 isLine truncated
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). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. 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, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
+13 -3
View File
@@ -90,7 +90,7 @@ def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLine
def prootPackPin = rootProject.file('tools/packs/proot/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 addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler'].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")
@@ -130,8 +130,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 15
versionName "0.6.0"
versionCode 16
versionName "0.6.7"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -234,6 +234,7 @@ android {
}
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
maxParallelForks = 2
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
@@ -286,6 +287,13 @@ if (uvcDir) {
}
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
def stageGogNotices = tasks.register('stageGogNotices', Copy) {
from(layout.projectDirectory.dir('src/main/rust/stores/vendor/xdelta3')) { include 'LICENSE', 'README.droiddeck.md' }
into(layout.buildDirectory.dir('gogNotices/licenses/xdelta3'))
}
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices'))
tasks.named('preBuild').configure { dependsOn(stageGogNotices) }
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
workingDir rootProject.projectDir
@@ -303,6 +311,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
onlyIf { !skipRust }
inputs.dir(rustCrateDir.dir('src'))
inputs.dir(rustCrateDir.dir('vendor'))
inputs.file(rustCrateDir.file('build.rs'))
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
inputs.property('ndkVersion', android.ndkVersion)
if (!skipRust) inputs.property('rustToolchain', providers.exec {
+21
View File
@@ -11,6 +11,7 @@
</intent>
</queries>
<uses-permission android:name="android.permission.REORDER_TASKS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -243,6 +244,26 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.DualScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity="${applicationId}.dualscreen"
android:theme="@style/SessionTheme"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.SessionTypingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity="${applicationId}.typing"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen"
android:resizeableActivity="true"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".gpu.SteamVrRestartActivity"
android:exported="false"
+124 -9
View File
@@ -93,6 +93,7 @@ static constexpr unsigned int FAKE_INPUT_MAJOR = 13;
static constexpr unsigned int FAKE_INPUT_EVENT_MINOR_BASE = 64;
static constexpr unsigned int FAKE_INPUT_JS_MINOR_BASE = 0;
static constexpr int UINPUT_EVENT_BASE = 16;
static constexpr int MOUSE_EVENT_SLOT = 4;
static constexpr int UINPUT_EVENT_LIMIT = 64;
static constexpr uint32_t UINPUT_DESCRIPTION_MAGIC = 0x46495544;
@@ -121,6 +122,12 @@ static constexpr size_t FAKE_INPUT_RING_HEADER_SIZE =
static constexpr size_t FAKE_INPUT_RING_SIZE =
FAKE_INPUT_RING_HEADER_SIZE +
(FAKE_INPUT_RING_CAPACITY * FAKE_INPUT_EVENT_SIZE);
static constexpr size_t DECK_IMU_BLOCK_SIZE = 64;
static constexpr size_t FAKE_INPUT_RING_PAD_TYPE_OFFSET = 44;
static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3,
PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE,
PAD_TYPE_MOUSE = 5;
static constexpr uint32_t PAD_TYPE_SET = 0x80000000u;
struct UinputDescription {
uint32_t magic;
@@ -149,6 +156,7 @@ struct FakeController {
size_t mapping_size = 0;
bool closed = false;
bool needs_keyframe = true;
bool mouse = false;
~FakeController() {
if (ring) munmap(ring, mapping_size);
@@ -607,7 +615,7 @@ open_fake_input_ring(const char *event, int flags) {
int slot = get_event_number(event);
bool virtual_pad = slot >= UINPUT_EVENT_BASE;
std::string ring_path = virtual_pad ? uinput_ring_path(slot) : get_ring_path_for_slot(slot);
size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE;
size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE;
if (ring_path.empty()) {
Logger::log("No input ring is configured for %s (slot %d)\n", event, slot);
errno = ENODEV;
@@ -651,9 +659,22 @@ open_fake_input_ring(const char *event, int flags) {
controller->uinput = description;
controller->mapping_size = mapping_size;
controller->generation = ring_generation(ring);
if (!virtual_pad) {
uint32_t pad_type = __atomic_load_n(
reinterpret_cast<volatile uint32_t *>(static_cast<uint8_t *>(mapping) + FAKE_INPUT_RING_SIZE +
FAKE_INPUT_RING_PAD_TYPE_OFFSET),
__ATOMIC_ACQUIRE);
controller->mouse = (pad_type & PAD_TYPE_SET) && (pad_type & ~PAD_TYPE_SET) == PAD_TYPE_MOUSE;
}
SnapshotState snap;
if (read_snapshot(ring, snap) && snap.generation == controller->generation)
capture_keyframe(*controller, snap);
if (read_snapshot(ring, snap) && snap.generation == controller->generation) {
if (controller->mouse) {
controller->read_seq = snap.write_seq;
controller->needs_keyframe = false;
} else {
capture_keyframe(*controller, snap);
}
}
{
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
controller_map()[fd] = controller;
@@ -1156,7 +1177,8 @@ struct DeckImu {
uint32_t pad_type;
uint32_t latency;
};
static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter");
static_assert(offsetof(DeckImu, pad_type) == FAKE_INPUT_RING_PAD_TYPE_OFFSET,
"DeckImu::pad_type matches FakeInputWriter and the ring reader");
static_assert(offsetof(DeckImu, latency) == 48, "DeckImu::latency matches FakeInputWriter");
static constexpr uint32_t LATENCY_CONTROLLER = 1, LATENCY_TOUCH = 2;
static constexpr int LOW_LATENCY_REPORT_INTERVAL_US = 1000;
@@ -1173,7 +1195,6 @@ struct DeckExtras {
uint32_t controls = 0;
};
static constexpr uint32_t DECK_IMU_MAGIC = 0x31554D49;
static constexpr size_t DECK_IMU_BLOCK_SIZE = 64;
static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes in the ring file");
struct TritonSharedData {
@@ -1270,10 +1291,6 @@ static bool fake_deck_enabled() {
return enabled == 1;
}
static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3,
PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE;
static constexpr uint32_t PAD_TYPE_SET = 0x80000000u;
__attribute__((visibility("hidden"))) static uint32_t session_pad_type() {
static int type = -1;
if (type < 0) {
@@ -1408,6 +1425,12 @@ __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const ch
if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false;
int number = get_event_number(event);
if (number < 0 || number >= UINPUT_EVENT_BASE) return false;
if (number == MOUSE_EVENT_SLOT && !strncmp(event, "event", 5)) {
char *fake = from_real_to_fake_path(pathname);
bool exposed = fake && path_exists(fake);
free(fake);
return !exposed;
}
return number == 0 || !process_is_steam_client();
}
@@ -3351,6 +3374,85 @@ static int copy_ioctl_bits(int op, void *destination, const unsigned char (&bits
return static_cast<int>(size);
}
static constexpr uint16_t MOUSE_VENDOR_ID = 0x1dd1;
static constexpr uint16_t MOUSE_PRODUCT_ID = 0x0004;
static int ioctl_mouse(int op, void *argp) {
int type = (op >> 8 & 0xFF);
int number = (op >> 0 & 0xFF);
if (type != 0x45) {
errno = EINVAL;
return -1;
}
switch (number) {
case 0x01: {
int version = 65536;
memcpy(argp, &version, sizeof(int));
return 0;
}
case 0x02: {
struct input_id id;
memset(&id, 0, sizeof(id));
id.bustype = 0x03;
id.vendor = MOUSE_VENDOR_ID;
id.product = MOUSE_PRODUCT_ID;
id.version = 1;
memcpy(argp, &id, sizeof(id));
return 0;
}
case 0x06:
if (argp && _IOC_SIZE(op)) snprintf(static_cast<char *>(argp), _IOC_SIZE(op), "DroidDeck Mouse");
return 0;
case 0x07:
if (argp && _IOC_SIZE(op)) snprintf(static_cast<char *>(argp), _IOC_SIZE(op), "usb-droiddeck/mouse");
return 0;
case 0x08:
if (argp && _IOC_SIZE(op)) static_cast<char *>(argp)[0] = '\0';
return 0;
case 0x09: {
unsigned char bitmask[(INPUT_PROP_MAX + 8) / 8] = {};
bitmask[INPUT_PROP_POINTER / 8] |= 1 << (INPUT_PROP_POINTER % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x18: {
unsigned char bitmask[(KEY_MAX + 8) / 8] = {};
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x20: {
unsigned char bitmask[(EV_MAX + 8) / 8] = {};
bitmask[EV_SYN / 8] |= 1 << (EV_SYN % 8);
bitmask[EV_KEY / 8] |= 1 << (EV_KEY % 8);
bitmask[EV_REL / 8] |= 1 << (EV_REL % 8);
bitmask[EV_MSC / 8] |= 1 << (EV_MSC % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x21: {
unsigned char bitmask[(KEY_MAX + 8) / 8] = {};
for (int button = BTN_LEFT; button <= BTN_TASK; button++)
if (button <= BTN_MIDDLE || button == BTN_SIDE || button == BTN_EXTRA)
bitmask[button / 8] |= 1 << (button % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x22: {
unsigned char bitmask[(REL_MAX + 8) / 8] = {};
for (uint16_t code : {REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES})
bitmask[code / 8] |= 1 << (code % 8);
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x23: {
unsigned char bitmask[(ABS_MAX + 8) / 8] = {};
return copy_ioctl_bits(op, argp, bitmask);
}
case 0x35: {
unsigned char bitmask[(FF_MAX + 8) / 8] = {};
return copy_ioctl_bits(op, argp, bitmask);
}
default:
errno = EINVAL;
return -1;
}
}
static bool wait_snapshot(std::shared_ptr<FakeController> fake,
std::unique_lock<std::recursive_mutex> &guard,
SnapshotState &snap) {
@@ -3417,6 +3519,13 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
int event_number = controller->second->slot;
const UinputDescription *made = controller->second->uinput;
if (controller->second->mouse) {
int result = ioctl_mouse(op, argp);
if (result >= 0) return result;
guard.unlock();
return forward_ioctl(fd, op, argp);
}
if (type == 0x45 && number == 0x1) {
Logger::log("Hooking ioctl EVIOCGVERSION for event %s\n", event);
int version = 65536;
@@ -3680,6 +3789,12 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
if (fake.needs_keyframe || snap.resync_seq != fake.resync_seq ||
snap.write_seq < fake.read_seq ||
snap.write_seq - fake.read_seq > FAKE_INPUT_RING_CAPACITY) {
if (fake.mouse) {
fake.read_seq = snap.write_seq;
fake.resync_seq = snap.resync_seq;
fake.needs_keyframe = false;
continue;
}
capture_keyframe(fake, snap);
} else {
size_t events = std::min<uint64_t>(requested, snap.write_seq - fake.read_seq);
@@ -33,6 +33,8 @@ add_library(droiddeckwayland SHARED
generated/banner-ahb-v1-protocol.c
src/framegen_bridge.c
src/framegen_jni.c
src/hudframes.c
src/hudframes_jni.c
)
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
+136 -10
View File
@@ -263,9 +263,14 @@ static struct wl_event_source *g_render_idle, *g_frame_timer;
static int g_dirty;
static int64_t g_dirty_since_ns;
volatile int g_render_on_commit = 1;
static int64_t g_rendered_tick_ns;
static int g_tick_held;
static int64_t g_hold_missed_ns;
#define HOLD_RETRY_NS 1000000000LL
#define PACE_BUFFERS 32
static struct {
unsigned ticks, ticks_late_2ms, renders, null_attaches, reattaches, presented, replaced, undrawn, distinct;
unsigned ticks, ticks_late_2ms, renders, commit_renders, null_attaches, reattaches, presented, replaced, undrawn, distinct;
int64_t tick_late_ns, tick_late_max_ns, wait_ns, wait_max_ns;
const void *buffers[PACE_BUFFERS];
uint64_t game_frames_seen;
@@ -327,6 +332,7 @@ static struct {
} g_perf;
static void schedule_render(void);
static void on_commit_idle(void *data);
static int64_t now_ns(void) {
struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
@@ -497,6 +503,7 @@ struct active_touch { struct surface *surface; int pointer_fallback; };
static struct active_touch g_touch_points[MAX_TOUCH_POINTS];
static void touch_cancel_surface(struct surface *surface);
static struct surface *g_grab;
static void publish_query_state(void);
static struct surface *g_key_target;
static struct surface *g_ime_click;
static int g_input_pipe[2] = {-1, -1};
@@ -590,6 +597,7 @@ static void map_toplevel(struct surface *s) {
raise_kept_on_top();
if (!g_desktop) g_key_target = s;
apply_zorder();
publish_query_state();
}
static void unmap_toplevel(struct surface *s) {
@@ -2116,7 +2124,21 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) {
unsigned droiddeck_stream_frames(void) { return atomic_load(&g_stream_frames); }
static int64_t g_scene_due_ns;
static int scene_held_for_limit(int64_t now, int64_t due, int limit, int unpaced) {
if (limit <= 0 || unpaced || due <= 0) return 0;
return now < due - 1000000LL;
}
static int64_t scene_next_due(int64_t now, int64_t due, int limit) {
if (limit <= 0) return 0;
const int64_t interval = 1000000000LL / limit;
return due > 0 && now - due < interval ? due + interval : now + interval;
}
static void render_scene(void) {
g_scene_due_ns = scene_next_due(now_ns(), g_scene_due_ns, g_fps_limit);
if (atomic_load(&g_stream_on)) { g_dirty_since_ns = 0; render_stream(); return; }
struct draw_list dl = {0};
struct surface *s;
@@ -2132,6 +2154,8 @@ static void render_scene(void) {
g_dirty_since_ns = 0;
}
g_dirty = 0;
g_rendered_tick_ns = g_tick_vsync_ns;
g_tick_held = 0;
g_hud_fresh = 0;
g_hdr_unimported = NULL;
wl_list_for_each(s, &g_surfaces, link) s->drawn = 0;
@@ -2292,15 +2316,31 @@ static void render_scene(void) {
static int on_fallback_timer(void *data) {
g_fallback_armed = 0;
if (g_dirty) render_scene();
if (!g_dirty) return 0;
if (scene_held_for_limit(now_ns(), g_scene_due_ns, g_fps_limit, g_release_unpaced)) schedule_render();
else render_scene();
return 0;
}
/* A new frame is drawn when it arrives instead of at the next screen tick, at most once per refresh,
while frames go straight back to the game (no compositor limit), without streaming or frame generation. */
static int commit_renders_on(void) {
return g_render_on_commit && g_release_unpaced && !atomic_load(&g_stream_on) && !vkp_framegen_active();
}
static int render_on_commit(void) {
return commit_renders_on() && !g_render_idle && g_rendered_tick_ns != g_tick_vsync_ns;
}
static void schedule_render(void) {
if (!g_dirty && !g_dirty_since_ns) g_dirty_since_ns = now_ns();
g_dirty = 1;
if (!g_display) return;
if (g_last_vsync_ns && now_ns() - g_last_vsync_ns < 100000000LL) return;
if (g_last_vsync_ns && now_ns() - g_last_vsync_ns < 100000000LL) {
if (render_on_commit())
g_render_idle = wl_event_loop_add_idle(wl_display_get_event_loop(g_display), on_commit_idle, NULL);
return;
}
if (g_fallback_armed) return;
if (!g_fallback_timer)
g_fallback_timer = wl_event_loop_add_timer(wl_display_get_event_loop(g_display),
@@ -2314,6 +2354,13 @@ static int scene_waits_for_game(int64_t now) {
return g_hud_surface && !g_hud_fresh && now - g_hud_last_ns < 100000000LL && vkp_framegen_active();
}
static void on_commit_idle(void *data) {
g_render_idle = NULL;
if (!g_dirty || !commit_renders_on() || g_rendered_tick_ns == g_tick_vsync_ns || scene_waits_for_game(now_ns())) return;
g_pace.commit_renders++;
render_scene();
}
static void on_vsync(int64_t frame_time_ns) {
int64_t now = now_ns();
g_perf.ticks++;
@@ -2330,9 +2377,23 @@ static void on_vsync(int64_t frame_time_ns) {
}
g_last_vsync_ns = now;
g_tick_vsync_ns = frame_time_ns;
if (vkp_apply_window_request()) g_dirty = 1;
if (vkp_effects_sync()) g_dirty = 1;
if (vkp_cardboard_pose_changed()) g_dirty = 1;
int tick_change = 0;
if (vkp_apply_window_request()) tick_change = 1;
if (vkp_effects_sync()) tick_change = 1;
if (vkp_cardboard_pose_changed()) tick_change = 1;
if (tick_change) g_dirty = 1;
/* A frame that arrived after this refresh was already drawn is left for the next arrival to draw;
drawing it here would block that arrival too and keep every frame waiting for the tick. A client
that draws again only after its frame callback sends no such arrival: once a held frame is still
waiting at the next tick, the tick draws every frame for a second before holding one again. */
if (g_tick_held && g_dirty) g_hold_missed_ns = now;
g_tick_held = 0;
if (!tick_change && commit_renders_on() && g_dirty_since_ns && now - g_dirty_since_ns < g_refresh_ns &&
now - g_hold_missed_ns >= HOLD_RETRY_NS) {
g_tick_held = 1;
return;
}
if (g_dirty && scene_held_for_limit(now, g_scene_due_ns, g_fps_limit, g_release_unpaced)) return;
if (g_dirty && !scene_waits_for_game(now)) render_scene();
}
@@ -2390,6 +2451,7 @@ static const struct wl_touch_interface touch_impl = { .release = touch_release }
static void touch_res_destroy(struct wl_resource *r) {
for (int i = 0; i < g_ntouches; i++)
if (g_touches[i].touch == r) { g_touches[i] = g_touches[--g_ntouches]; break; }
publish_query_state();
}
static void seat_get_pointer(struct wl_client *c, struct wl_resource *r, uint32_t id) {
@@ -2427,6 +2489,7 @@ static void seat_get_touch(struct wl_client *c, struct wl_resource *r, uint32_t
g_touches[g_ntouches].focus = NULL;
g_ntouches++;
}
publish_query_state();
}
static void seat_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_seat_interface seat_impl = {
@@ -2542,6 +2605,7 @@ struct relative_pointer {
static struct wl_list g_relative_pointers;
static double g_ptr_x, g_ptr_y;
static int g_ptr_known;
static double g_raw_x, g_raw_y;
static int g_raw_valid;
@@ -2606,6 +2670,7 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
constraint_origin(k, &ox, &oy);
g_ptr_x = ox + k->hint_x;
g_ptr_y = oy + k->hint_y;
g_ptr_known = 1;
}
g_raw_valid = 0;
if (k->gave_keys) {
@@ -2702,6 +2767,7 @@ static void constraints_surface_commit(struct surface *s) {
constraint_origin(k, &ox, &oy);
g_ptr_x = ox + k->hint_x;
g_ptr_y = oy + k->hint_y;
g_ptr_known = 1;
if (log_budget())
droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
}
@@ -2896,6 +2962,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
}
if (k) confine_clamp(k, &x, &y);
g_ptr_x = x; g_ptr_y = y;
g_ptr_known = 1;
if (k) {
target = k->surface;
} else if (g_desktop) {
@@ -2906,6 +2973,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
if (button && !pressed) g_grab = NULL;
}
}
publish_query_state();
if (!target) return;
struct wl_client *client = wl_resource_get_client(target->resource);
@@ -2921,9 +2989,9 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
// 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))
int rel_consumed = send_relative_motion(client, dx, dy);
if (!(k && k->is_lock) && !(relative && rel_consumed) && (!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) {
if (button)
wl_pointer_send_button(sp->ptr, wl_display_next_serial(g_display), t, button,
@@ -3062,6 +3130,63 @@ static struct surface *touch_target_at(double x, double y) {
return g_grab ? g_grab : g_key_target;
}
static pthread_mutex_t g_query_lock = PTHREAD_MUTEX_INITIALIZER;
struct query_snapshot {
int has_position;
int input_x, input_y;
int focus_takes_touch;
};
static struct query_snapshot g_query = { 0, 0, 0, 1 };
static void publish_query_state(void) {
struct surface *target = g_key_target ? g_key_target : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y));
int takes_touch = 1;
if (target) {
struct wl_client *client = wl_resource_get_client(target->resource);
struct seat_touch *st;
takes_touch = 0;
for_each_touch_of(client, st) takes_touch = 1;
}
int w = 0, h = 0, ow = 0, oh = 0, input_x = 0, input_y = 0, has_position = 0;
scene_size(&w, &h);
vkp_output_size(&ow, &oh);
if (g_ptr_known && w > 0 && h > 0) {
double ox, oy;
if (ow > 0 && oh > 0 && vkp_scene_to_output(g_ptr_x, g_ptr_y, &ox, &oy)) {
input_x = (int)(ox * INPUT_SPACE_W / ow);
input_y = (int)(oy * INPUT_SPACE_H / oh);
} else {
input_x = (int)(g_ptr_x * INPUT_SPACE_W / w);
input_y = (int)(g_ptr_y * INPUT_SPACE_H / h);
}
has_position = 1;
}
pthread_mutex_lock(&g_query_lock);
g_query.focus_takes_touch = takes_touch;
g_query.has_position = has_position;
g_query.input_x = input_x;
g_query.input_y = input_y;
pthread_mutex_unlock(&g_query_lock);
}
int droiddeck_focus_takes_touch(void) {
pthread_mutex_lock(&g_query_lock);
int takes_touch = g_query.focus_takes_touch;
pthread_mutex_unlock(&g_query_lock);
return takes_touch;
}
int droiddeck_cursor_input_pos(int *x, int *y) {
pthread_mutex_lock(&g_query_lock);
int has_position = g_query.has_position;
int input_x = g_query.input_x, input_y = g_query.input_y;
pthread_mutex_unlock(&g_query_lock);
if (!has_position) return 0;
*x = input_x;
*y = input_y;
return 1;
}
static void touch_down(int id, double x, double y) {
if (id < 0 || id >= MAX_TOUCH_POINTS) return;
struct surface *target = touch_target_at(x, y);
@@ -3161,6 +3286,7 @@ static void deliver_touch(const struct input_msg *m) {
else if (m->p1 == 1) touch_motion(m->p2, x, y);
else if (m->p1 == 2) touch_up(m->p2, x, y);
else if (m->p1 == 3) touch_cancel_all();
publish_query_state();
}
static void key_event(uint32_t evdev, int pressed);
@@ -3458,13 +3584,13 @@ static int on_stats_timer(void *data) {
if (g_pace.ticks || g_pace.renders || game || g_pace.reattaches || g_pace.null_attaches || g_pace.distinct ||
g_pace.presented || g_pace.replaced || g_pace.undrawn) {
droiddeck_log("pacing", "last 10 s: tick reaches compositor %.2f/%.2f ms late (%u ticks, %u over 2 ms) | "
"new frame waits %.2f/%.2f ms to be drawn (%u scenes) | %llu game frames, %u re-attached after an "
"new frame waits %.2f/%.2f ms to be drawn (%u scenes, %u on arrival) | %llu game frames, %u re-attached after an "
"empty attach, %u empty attaches, %u distinct buffers | feedback %u shown, %u replaced before drawing, "
"%u not drawn",
g_pace.ticks ? (double)g_pace.tick_late_ns / 1e6 / g_pace.ticks : 0.0, (double)g_pace.tick_late_max_ns / 1e6,
g_pace.ticks, g_pace.ticks_late_2ms,
g_pace.renders ? (double)g_pace.wait_ns / 1e6 / g_pace.renders : 0.0, (double)g_pace.wait_max_ns / 1e6,
g_pace.renders, (unsigned long long)game, g_pace.reattaches, g_pace.null_attaches, g_pace.distinct,
g_pace.renders, g_pace.commit_renders, (unsigned long long)game, g_pace.reattaches, g_pace.null_attaches, g_pace.distinct,
g_pace.presented, g_pace.replaced, g_pace.undrawn);
}
{
@@ -0,0 +1,99 @@
#include <fcntl.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "hudframes.h"
/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */
#define HUD_FRAMES_MAGIC 0x46484444u
#define HUD_FRAMES_HEADER 128u
#define HUD_FRAMES_ENTRY 16u
struct hud_frames {
const uint8_t *map;
size_t size;
uint64_t seen;
};
static uint32_t load32(const uint8_t *p) { return __atomic_load_n((const uint32_t *)p, __ATOMIC_ACQUIRE); }
static uint64_t load64(const uint8_t *p) { return __atomic_load_n((const uint64_t *)p, __ATOMIC_ACQUIRE); }
struct hud_frames *hud_frames_open(const char *path) {
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return NULL;
struct stat st;
void *map = MAP_FAILED;
if (fstat(fd, &st) == 0 && st.st_size >= (off_t)HUD_FRAMES_HEADER)
map = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (map == MAP_FAILED) return NULL;
struct hud_frames *h = calloc(1, sizeof(*h));
if (!h) {
munmap(map, (size_t)st.st_size);
return NULL;
}
h->map = map;
h->size = (size_t)st.st_size;
return h;
}
void hud_frames_close(struct hud_frames *h) {
if (!h) return;
munmap((void *)h->map, h->size);
free(h);
}
static uint32_t capacity(const struct hud_frames *h) {
if (load32(h->map) != HUD_FRAMES_MAGIC) return 0;
uint32_t cap;
memcpy(&cap, h->map + 8, 4);
if (cap == 0 || HUD_FRAMES_HEADER + (size_t)cap * HUD_FRAMES_ENTRY > h->size) return 0;
return cap;
}
int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max) {
uint32_t cap = capacity(h);
if (!cap || max <= 0) return -1;
uint64_t count = load64(h->map + 16);
if (count < h->seen) h->seen = 0;
uint64_t from = h->seen;
if (count - from > cap) from = count - cap;
if (count - from > (uint64_t)max) from = count - (uint64_t)max;
int n = 0;
for (uint64_t i = from; i < count; i++, n++) {
const uint8_t *e = h->map + HUD_FRAMES_HEADER + (i % cap) * HUD_FRAMES_ENTRY;
memcpy(&frametimes[n], e, 8);
memcpy(&times[n], e + 8, 8);
}
/* The writer may have lapped the ring while we copied: drop what it overwrote. */
uint64_t after = load64(h->map + 16);
uint64_t valid = after > cap ? after - cap : 0;
if (from < valid) {
int skip = (int)(valid - from < (uint64_t)n ? valid - from : (uint64_t)n);
memmove(frametimes, frametimes + skip, (size_t)(n - skip) * sizeof(int64_t));
memmove(times, times + skip, (size_t)(n - skip) * sizeof(int64_t));
n -= skip;
}
h->seen = count;
return n;
}
int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len) {
if (!capacity(h) || engine_len == 0) return 0;
for (int tries = 0; tries < 8; tries++) {
uint32_t seq = load32(h->map + 24);
if (seq & 1u) continue;
memcpy(&info[0], h->map + 12, 4);
memcpy(&info[1], h->map + 28, 16);
size_t n = engine_len - 1 < 40 ? engine_len - 1 : 40;
memcpy(engine, h->map + 44, n);
engine[n] = '\0';
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (__atomic_load_n((const uint32_t *)(h->map + 24), __ATOMIC_RELAXED) == seq) return 1;
}
return 0;
}
@@ -0,0 +1,16 @@
#ifndef DROIDDECK_HUDFRAMES_H
#define DROIDDECK_HUDFRAMES_H
#include <stddef.h>
#include <stdint.h>
struct hud_frames;
struct hud_frames *hud_frames_open(const char *path);
void hud_frames_close(struct hud_frames *h);
/* New frames since the last poll, oldest first; -1 while the file holds no valid ring. */
int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max);
/* info: pid, output width, output height, refresh mHz, Steam focused. */
int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len);
#endif
@@ -0,0 +1,55 @@
#include <jni.h>
#include <stdint.h>
#include <stdlib.h>
#include "hudframes.h"
JNIEXPORT jlong JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativeOpen(JNIEnv *env, jclass clazz, jstring path) {
(void)clazz;
const char *p = path ? (*env)->GetStringUTFChars(env, path, NULL) : NULL;
if (!p) return 0;
struct hud_frames *h = hud_frames_open(p);
(*env)->ReleaseStringUTFChars(env, path, p);
return (jlong)(intptr_t)h;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativeClose(JNIEnv *env, jclass clazz, jlong handle) {
(void)env; (void)clazz;
hud_frames_close((struct hud_frames *)(intptr_t)handle);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativePoll(JNIEnv *env, jclass clazz, jlong handle,
jlongArray frametimes, jlongArray times) {
(void)clazz;
struct hud_frames *h = (struct hud_frames *)(intptr_t)handle;
if (!h || !frametimes || !times) return -1;
jsize max = (*env)->GetArrayLength(env, frametimes);
jsize max2 = (*env)->GetArrayLength(env, times);
if (max2 < max) max = max2;
if (max <= 0) return -1;
int64_t *ft = malloc(sizeof(int64_t) * (size_t)max);
int64_t *tm = malloc(sizeof(int64_t) * (size_t)max);
int n = ft && tm ? hud_frames_poll(h, ft, tm, max) : -1;
if (n > 0) {
(*env)->SetLongArrayRegion(env, frametimes, 0, n, (const jlong *)ft);
(*env)->SetLongArrayRegion(env, times, 0, n, (const jlong *)tm);
}
free(ft);
free(tm);
return n;
}
JNIEXPORT jstring JNICALL
Java_com_droiddeck_launcher_hud_HudFrames_nativeInfo(JNIEnv *env, jclass clazz, jlong handle, jintArray info) {
(void)clazz;
const struct hud_frames *h = (const struct hud_frames *)(intptr_t)handle;
if (!h || !info || (*env)->GetArrayLength(env, info) < 5) return NULL;
int32_t values[5];
char engine[41];
if (!hud_frames_info(h, values, engine, sizeof(engine))) return NULL;
(*env)->SetIntArrayRegion(env, info, 0, 5, (const jint *)values);
return (*env)->NewStringUTF(env, engine);
}
@@ -384,6 +384,13 @@ int vkp_output_to_scene(double ox, double oy, double *sx, double *sy) {
return 1;
}
int vkp_scene_to_output(double sx, double sy, double *ox, double *oy) {
if (!g_map.valid || g_map.kx <= 0 || g_map.ky <= 0) return 0;
*ox = sx * g_map.kx + g_map.off_x;
*oy = sy * g_map.ky + g_map.off_y;
return 1;
}
void vkp_output_size(int *w, int *h) {
*w = (int)g_extent.width;
*h = (int)g_extent.height;
@@ -16,6 +16,7 @@ enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
void vk_present_set_scale_mode(int mode, int alignment);
int vkp_game_picture_rect(float rect[4]);
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
int vkp_scene_to_output(double sx, double sy, double *ox, double *oy);
void vkp_output_size(int *w, int *h);
struct vkp_image;
@@ -27,6 +27,8 @@ extern int droiddeck_cursor_snapshot(int *out, int cap);
extern int droiddeck_cursor_serial(void);
extern int droiddeck_hud_frame_times(float *out, int len, int64_t *total);
extern void droiddeck_wayland_send_key(int evdev, int state);
extern int droiddeck_focus_takes_touch(void);
extern int droiddeck_cursor_input_pos(int *x, int *y);
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
extern void droiddeck_stream_set(int on);
@@ -36,6 +38,7 @@ extern void droiddeck_wayland_output_resized(void);
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
extern volatile int g_fps_limit;
extern volatile int g_release_unpaced;
extern volatile int g_render_on_commit;
extern volatile int g_hide_shell;
extern volatile int g_zero_copy;
extern volatile unsigned g_zero_copy_last;
@@ -295,6 +298,25 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
droiddeck_wayland_send_key(evdev, state);
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeFocusTakesTouch(
JNIEnv *env, jclass clazz) {
(void)env; (void)clazz;
return droiddeck_focus_takes_touch() ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorInputPos(
JNIEnv *env, jclass clazz, jintArray out) {
(void)clazz;
if (!out || (*env)->GetArrayLength(env, out) < 2) return JNI_FALSE;
int pos[2];
if (!droiddeck_cursor_input_pos(&pos[0], &pos[1])) return JNI_FALSE;
jint jpos[2] = {pos[0], pos[1]};
(*env)->SetIntArrayRegion(env, out, 0, 2, jpos);
return JNI_TRUE;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput(
JNIEnv *env, jclass clazz, jint type, jint a, jint b) {
@@ -587,6 +609,12 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLookName(JNIEnv *
free(s);
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetRenderOnCommit(JNIEnv *env, jclass clazz, jboolean on) {
g_render_on_commit = on ? 1 : 0;
__android_log_print(ANDROID_LOG_INFO, TAG, "render on commit %s", on ? "on" : "off");
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFpsLimit(JNIEnv *env, jclass clazz, jint fps) {
g_fps_limit = fps > 0 ? fps : 0;
@@ -46,6 +46,7 @@ class App : Application() {
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
BwrapSpawner.start(this)
com.droiddeck.launcher.runtime.RootSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
com.droiddeck.launcher.stores.StoresState.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
@@ -44,6 +44,7 @@ import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.core.CpuCores
@@ -108,6 +109,8 @@ class FrontEndActivity : ComponentActivity() {
private var ready by mutableStateOf(false)
private var removalPending by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var catalogFresh = false
private var componentsQueueBusy by mutableStateOf(false)
private var busy by mutableStateOf(false)
private var stage by mutableStateOf("")
private var percent by mutableIntStateOf(-1)
@@ -212,6 +215,7 @@ class FrontEndActivity : ComponentActivity() {
}
private val mediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
usbScan++
ui.removeCallbacks(mediaRefresh)
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
}
@@ -471,6 +475,16 @@ class FrontEndActivity : ComponentActivity() {
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backActionsInverted by mutableStateOf(false)
private var gameStorage by mutableStateOf("")
private var slowUsb by mutableStateOf<com.droiddeck.launcher.session.UsbLink.SlowDrive?>(null)
private var usbScan = 0
private var slowUsbNever by mutableStateOf(false)
private fun showSlowUsb(drive: com.droiddeck.launcher.session.UsbLink.SlowDrive?) {
val off = slowUsbNever
if (off) SessionPrefs.setSlowUsbWarningOff(this)
slowUsbNever = false
slowUsb = drive.takeUnless { off }
}
private var storageDiagnostics by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
@@ -484,6 +498,8 @@ class FrontEndActivity : ComponentActivity() {
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var gameDebugLogsEnabled by mutableStateOf(false)
private var gameDebugLogsNeedsRestart by mutableStateOf(false)
private var vacDiagnostics by mutableStateOf(false)
private var agentCommands by mutableStateOf(false)
private var clearCachedApps by mutableStateOf(false)
@@ -511,12 +527,23 @@ class FrontEndActivity : ComponentActivity() {
}
}
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var dualDisplayId by mutableStateOf(-1)
private var dualScreenDevice by mutableStateOf(false)
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenLauncher by mutableStateOf(true)
private var dualScreenControls by mutableStateOf(true)
private var launcherStarted = false
private lateinit var displayManager: DisplayManager
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
}
private val secondScreenTouchListener = object : android.hardware.input.InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) = refreshSecondScreenDisplays()
override fun onInputDeviceRemoved(deviceId: Int) = refreshSecondScreenDisplays()
override fun onInputDeviceChanged(deviceId: Int) = refreshSecondScreenDisplays()
}
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshHomeAppState()
@@ -531,6 +558,10 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show()
return
}
SessionState.installing != null && !SessionState.running -> {
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
compat -> Unit
pkgStage != null -> {
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
@@ -556,12 +587,12 @@ class FrontEndActivity : ComponentActivity() {
}
private fun launchFlooded(f: PendingFlood) {
if (busy || LinuxRuntimeInstaller.isBusy() || protons.protonBusyId != null ||
ProtonExtras.installInProgress || pkgStage != null) {
if (busy || LinuxRuntimeInstaller.isBusy() || (SessionState.installing != null && !SessionState.running) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
flood = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
if (!busy && !LinuxRuntimeInstaller.isBusy() &&
if (!busy && !LinuxRuntimeInstaller.isBusy() && !(SessionState.installing != null && !SessionState.running) &&
(protons.protonBusyId != null || ProtonExtras.installInProgress)) {
compatFlood = com.droiddeck.launcher.ui.Themes.byId(theme).signal
waitForCompat(f.intent)
@@ -595,7 +626,7 @@ class FrontEndActivity : ComponentActivity() {
}
if (!compatLaunchVisible) { ui.postDelayed(this, 250); return }
val problem = protons.protonProblem.takeIf { waiting.menuInstall }
if (problem == null && (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null)) {
if (problem == null && (busy || LinuxRuntimeInstaller.isBusy() || (SessionState.installing != null && !SessionState.running) || pkgStage != null)) {
waiting.loading.say(pkgStage ?: stage.takeIf { it.isNotBlank() } ?: getString(R.string.main_wait_runtime),
com.droiddeck.launcher.session.LoadingState.Topic.RUNTIME, readable = true)
waiting.loading.percent = if (pkgStage != null) pkgPercent else percent
@@ -642,7 +673,23 @@ class FrontEndActivity : ComponentActivity() {
private var shortcutPicker by mutableStateOf(false)
private var shortcutLibraryScanning by mutableStateOf(false)
@Volatile private var libraryScanGeneration = 0
@Volatile private var lastComponentRestore = 0L
@Volatile private var componentRestoreRetry = false
private val componentRestoreLock = Any()
private fun retryComponentRestore() {
val run = synchronized(componentRestoreLock) {
componentRestoreRetry = false
val now = android.os.SystemClock.elapsedRealtime()
(!isDestroyed && now - lastComponentRestore >= COMPONENT_RESTORE_MS).also { if (it) lastComponentRestore = now }
}
if (run) Thread({
runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) }
}, "components-restore").start()
}
private var pendingGameLink: String? = null
private var gameLinkReading = false
private var gameLinkTried = false
private var gameSyncFolder by mutableStateOf<String?>(null)
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
@@ -683,16 +730,25 @@ class FrontEndActivity : ComponentActivity() {
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
gameLinkReading = false
gameLinkTried = false
if (GameLaunchIntent.accepts(request.action)) {
gameLinkReading = true
com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
val copy = Intent(request)
Thread({
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
gameLinkReading = false
pendingGameLink = id
if (id == null) {
syncDualPanel()
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
} else refresh()
} else {
handleGameLink(steamGames, scanned = false)
refresh()
}
}
}
}, "game-launch-file").start()
@@ -895,15 +951,21 @@ class FrontEndActivity : ComponentActivity() {
ui.postDelayed(poll, SESSION_SWITCH_POLL_MS)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
private fun handleGameLink(games: List<Library.SteamGame>, scanned: Boolean = true) {
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
val id = pendingGameLink ?: return
if (!scanned && games.none { it.gameIdString == id }) return
gameLinkTried = true
ui.postDelayed({
if (!isDestroyed && lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) syncDualPanel()
}, LINK_PANEL_MS)
val game = games.firstOrNull { it.gameIdString == id }
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
syncDualPanel()
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game, returnHome = true)) {
pendingGameLink = null
@@ -958,6 +1020,13 @@ class FrontEndActivity : ComponentActivity() {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
)
slowUsb?.let { drive ->
com.droiddeck.launcher.ui.SlowUsbDialog(drive, slowUsbNever, { slowUsbNever = it }) {
com.droiddeck.launcher.session.UsbLink.markShown(this, drive)
showSlowUsb(null)
checkUsbLink()
}
}
val page: (@Composable () -> Unit)? = when {
showPerformance -> { { PerformanceHost() } }
showComponents -> { { ComponentsHost() } }
@@ -975,6 +1044,7 @@ class FrontEndActivity : ComponentActivity() {
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
@@ -991,6 +1061,11 @@ class FrontEndActivity : ComponentActivity() {
integrationFocus = integrationFocus,
androidApps = androidApps,
secondScreenDisplays = secondScreenDisplays,
dualScreen = dualDisplayId >= 0 && com.droiddeck.launcher.ui.DualScreenPanel.available,
dualScreenDevice = dualScreenDevice,
dualScreenMode = dualScreenMode,
dualScreenLauncher = dualScreenLauncher,
dualScreenControls = dualScreenControls,
packages = packageRows,
packageCatalogLoading = catalogLoading,
packageBusyId = pkgId,
@@ -1131,8 +1206,21 @@ class FrontEndActivity : ComponentActivity() {
.putExtra(com.droiddeck.launcher.files.FileManagerActivity.EXTRA_START_DIR, dir.absolutePath))
},
onLogs = {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
val changed = SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
if (!changed) android.widget.Toast.makeText(
this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG,
).show()
logsEnabled = SessionPrefs.logsEnabled(this)
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
},
onGameDebugLogs = {
val changed = SessionPrefs.setGameDebugLogsEnabled(this, !SessionPrefs.gameDebugLogsEnabled(this))
if (!changed) android.widget.Toast.makeText(
this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG,
).show()
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
},
onVacDiagnostics = {
SessionPrefs.setVacDiagnostics(this, !SessionPrefs.vacDiagnostics(this))
@@ -1206,6 +1294,18 @@ class FrontEndActivity : ComponentActivity() {
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view },
onLibraryChanged = { refreshAddedGames(); refresh() },
onDualScreenMode = { mode ->
SessionPrefs.setDualScreenMode(this, mode)
refreshSecondScreenDisplays()
},
onDualScreenLauncher = { on ->
SessionPrefs.setDualScreenLauncher(this, on)
refreshSecondScreenDisplays()
},
onDualScreenControls = { on ->
SessionPrefs.setDualScreenSessionControls(this, on)
dualScreenControls = on
},
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
@@ -1294,7 +1394,7 @@ class FrontEndActivity : ComponentActivity() {
showPhantomGate = true
},
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onStartPairingGuide = { displayId -> PairingGuideActivity.start(this, displayId) },
onStartPairingGuide = { displayId, toggleOnly -> PairingGuideActivity.start(this, displayId, toggleOnly) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
@@ -1436,6 +1536,9 @@ class FrontEndActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
if (intent.action == SteamIntegration.ACTION_SETUP) {
gameLinkReading = false
pendingGameLink = null
syncDualPanel()
readIntegrationIntent(intent)
return
}
@@ -1529,12 +1632,14 @@ class FrontEndActivity : ComponentActivity() {
override fun onPause() {
compatLaunchVisible = false
if (android.os.Build.VERSION.SDK_INT >= 29) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
super.onPause()
}
override fun onResume() {
super.onResume()
compatLaunchVisible = true
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
orientation = SessionPrefs.orientation(this)
launcherOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
requestedOrientation = launcherOrientation
@@ -1552,14 +1657,21 @@ class FrontEndActivity : ComponentActivity() {
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post { if (showComponents) components.refreshComponents() }
}, "components-queue").start()
else if (showComponents) components.refreshComponents()
catalogFresh = false
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
componentsQueueBusy = true
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
autoUpdateRuntime()
}
}, "components-queue").start()
} else if (showComponents) components.refreshComponents()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
@@ -1582,6 +1694,8 @@ class FrontEndActivity : ComponentActivity() {
addDataScheme("file")
})
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
getSystemService(android.hardware.input.InputManager::class.java)?.registerInputDeviceListener(secondScreenTouchListener, ui)
launcherStarted = true
refreshSecondScreenDisplays()
getSystemService(android.content.pm.LauncherApps::class.java).registerCallback(packageCallback, ui)
HomeWidgets.startListening(this)
@@ -1596,6 +1710,7 @@ class FrontEndActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (slowUsbNever) SessionPrefs.setSlowUsbWarningOff(this)
saveCompatLaunch(outState)
pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) }
switchingTo?.let {
@@ -1623,6 +1738,9 @@ class FrontEndActivity : ComponentActivity() {
com.droiddeck.launcher.stores.StoresState.libraryListener = null
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
getSystemService(android.hardware.input.InputManager::class.java)?.unregisterInputDeviceListener(secondScreenTouchListener)
launcherStarted = false
com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
flood = null
returning = null
floodProgress = 0f
@@ -1656,13 +1774,31 @@ class FrontEndActivity : ComponentActivity() {
if (!::displayManager.isInitialized) return
// The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
dualScreenMode = SessionPrefs.dualScreenMode(this)
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
dualScreenLauncher = SessionPrefs.dualScreenLauncher(this)
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
dualDisplayId = if (dualScreenLauncher) target?.displayId ?: -1 else -1
syncDualPanel()
}
private fun syncDualPanel() {
if (launchPending()) return
if (launcherStarted && dualDisplayId >= 0) com.droiddeck.launcher.ui.DualScreenPanel.show(this, dualDisplayId, taskId)
else com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
}
private fun launchPending(): Boolean = gameLinkReading || (pendingGameLink != null && !gameLinkTried)
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
val onOwnScreen = displayId == null || displayId == (display?.displayId ?: Display.DEFAULT_DISPLAY)
if (onOwnScreen) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
try {
HomeApp.launch(this, app, displayId)
} catch (_: Exception) {
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
@@ -2177,6 +2313,7 @@ class FrontEndActivity : ComponentActivity() {
}
private fun refreshAddedGames() {
checkUsbLink()
sgdbKeyKnown()
fetchAddedGameArt()
}
@@ -2281,6 +2418,20 @@ class FrontEndActivity : ComponentActivity() {
}
SessionPrefs.setGameStorage(this, path, label)
gameStorage = path
checkUsbLink()
}
private fun checkUsbLink() {
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { ui.post { checkUsbLink() }; return }
val scan = ++usbScan
Thread({
val drive = runCatching { com.droiddeck.launcher.session.UsbLink.slowLibrary(this) }.getOrNull()
ui.post {
if (scan != usbScan || isFinishing) return@post
val due = drive?.takeIf { com.droiddeck.launcher.session.UsbLink.stillDue(this, it) }
if (due == null) { if (slowUsb != null) showSlowUsb(null) } else if (slowUsb?.key != due.key) showSlowUsb(due)
}
}, "usb-link").start()
}
private fun refreshCores() {
@@ -2307,6 +2458,10 @@ class FrontEndActivity : ComponentActivity() {
refreshPhantomStatus()
}
private fun gameDebugLogsNeedsRestart(): Boolean =
SessionPrefs.logsEnabled(this) && SessionPrefs.gameDebugLogsEnabled(this) &&
!SessionPaths.currentSessionLogsRetained(this)
private fun refresh() {
// Until the scan finishes, the other client's games would launch in this one.
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
@@ -2325,9 +2480,12 @@ class FrontEndActivity : ComponentActivity() {
installed = LinuxRuntimeInstaller.installedVersion(this)
ready = LinuxRuntime.isInstalled(this)
removalPending = LinuxRuntimeInstaller.hasRemovalPending(this)
ui.post { autoUpdateRuntime() }
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
clearCachedApps = SessionPrefs.clearCachedApps(this)
@@ -2365,15 +2523,32 @@ class FrontEndActivity : ComponentActivity() {
}
val scannedArch = SessionPrefs.activeSteamClientArch(this)
val scannedX64 = scannedArch == "x86_64"
val games = if (ready) Library.launchableGames(this, arch = scannedArch) else emptyList()
val games = if (ready || com.droiddeck.launcher.runtime.LinuxRuntime.hasRootfs(this)) Library.launchableGames(this, arch = scannedArch) else emptyList()
val all = games.distinctBy { it.gameId }
if (ready && scanGeneration == libraryScanGeneration) com.droiddeck.launcher.frontend.LibraryCache.save(this, all, scannedX64)
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
if (ready && (!componentSweepDone || endedSession != null)) {
val sweepDue = ready && (!componentSweepDone || endedSession != null)
if (sweepDue) {
componentSweepDone = true
endedSession?.let { componentSweepSession = it }
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
}
val restoreDue = synchronized(componentRestoreLock) {
val now = android.os.SystemClock.elapsedRealtime()
val due = ready && (lastComponentRestore == 0L || now - lastComponentRestore >= COMPONENT_RESTORE_MS)
if (due) {
lastComponentRestore = now
} else if (ready && !componentRestoreRetry) {
componentRestoreRetry = true
ui.postDelayed({ retryComponentRestore() }, (COMPONENT_RESTORE_MS - (now - lastComponentRestore)).coerceAtLeast(0L))
}
due
}
if (restoreDue || sweepDue) ui.postDelayed({
Thread({
if (restoreDue) runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) }
if (sweepDue) com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all)
}, "components-restore").start()
}, 10_000)
ui.post {
if (scanGeneration == libraryScanGeneration) {
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
@@ -2414,6 +2589,10 @@ class FrontEndActivity : ComponentActivity() {
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
if (busy || LinuxRuntimeInstaller.isBusy()) return false
if (SessionState.installing != null && !SessionState.running) {
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (com.droiddeck.launcher.storage.StorageState.cleaning) {
android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show()
return false
@@ -2463,7 +2642,7 @@ class FrontEndActivity : ComponentActivity() {
private fun runtimeChangesBlocked(): Boolean =
busy || LinuxRuntimeInstaller.isBusy() || SessionState.running ||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null || componentsQueueBusy ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeDesktop() {
@@ -2523,15 +2702,32 @@ class FrontEndActivity : ComponentActivity() {
}, "runtime-operation").start()
}
private fun autoUpdateRuntime() {
val release = available?.takeIf { catalogFresh } ?: return
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
if (release.version == installed || installed == null) return
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
install(release)
}
}
private fun checkCatalog() {
val release = LinuxRuntimeInstaller.fetchRelease()
Log.i(TAG, "catalog: " + (release?.version ?: "unreachable"))
ui.post { if (release != null) available = release }
ui.post {
if (release == null) return@post
available = release
catalogFresh = true
autoUpdateRuntime()
}
}
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
private const val COMPONENT_RESTORE_MS = 60_000L
private const val LINK_PANEL_MS = 1500L
private const val SESSION_SWITCH_WAIT_MS = 30_000L
private const val SESSION_SWITCH_POLL_MS = 250L
private const val KEY_PENDING_SWITCH = "pending_switch"
@@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.GlobalSettings
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage
import com.droiddeck.launcher.ui.DroidDeckTheme
@@ -67,9 +68,10 @@ import com.droiddeck.launcher.ui.SecondaryButton
import kotlinx.coroutines.delay
class PairingGuideActivity : ComponentActivity() {
enum class Step { BUILD_NUMBER, WIRELESS_DEBUGGING, PAIR, CODE, WORKING, DONE, FAILED }
enum class Step { BUILD_NUMBER, CHILD_PROCESS, WIRELESS_DEBUGGING, PAIR, CODE, WORKING, DONE, FAILED }
private var settingsDisplay: Int? = null
private var toggleOnly = false
private var inPip by mutableStateOf(false)
private var launchPending = false
@@ -78,6 +80,7 @@ class PairingGuideActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settingsDisplay = intent.getIntExtra(EXTRA_DISPLAY, -1).takeIf { it >= 0 }
toggleOnly = intent.getBooleanExtra(EXTRA_TOGGLE_ONLY, false)
setContent { DroidDeckTheme { Guide() } }
launchPending = savedInstanceState == null
}
@@ -86,6 +89,7 @@ class PairingGuideActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
settingsDisplay = intent.getIntExtra(EXTRA_DISPLAY, -1).takeIf { it >= 0 }
toggleOnly = intent.getBooleanExtra(EXTRA_TOGGLE_ONLY, false)
launchPending = true
}
@@ -108,7 +112,7 @@ class PairingGuideActivity : ComponentActivity() {
private fun launchSettings() {
launchPending = false
openSettings(stepFor(this, WirelessAdbPairingService.stage.value))
openSettings(stepFor(SettingsState.read(this), WirelessAdbPairingService.stage.value, toggleOnly))
}
private fun enterPip() {
@@ -121,7 +125,9 @@ class PairingGuideActivity : ComponentActivity() {
val intent = if (step == Step.BUILD_NUMBER) {
Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).putExtra(HIGHLIGHT_KEY, "build_number")
} else {
Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).putExtra(HIGHLIGHT_KEY, "toggle_adb_wireless")
Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply {
if (step != Step.CHILD_PROCESS) putExtra(HIGHLIGHT_KEY, "toggle_adb_wireless")
}
}.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val display = settingsDisplay
runCatching {
@@ -147,10 +153,10 @@ class PairingGuideActivity : ComponentActivity() {
delay(500)
}
}
val step = stepFor(settingsState, stage)
val step = stepFor(settingsState, stage, toggleOnly)
var previous by remember { mutableStateOf(step) }
LaunchedEffect(step) {
if (previous == Step.BUILD_NUMBER && step == Step.WIRELESS_DEBUGGING) openSettings(step)
if (previous == Step.BUILD_NUMBER && (step == Step.WIRELESS_DEBUGGING || step == Step.CHILD_PROCESS)) openSettings(step)
previous = step
when (step) {
Step.DONE -> { delay(1_500); returnToApp() }
@@ -158,7 +164,7 @@ class PairingGuideActivity : ComponentActivity() {
else -> Unit
}
}
LaunchedEffect(stage) { if (stage == Stage.Idle) finish() }
LaunchedEffect(stage) { if (stage == Stage.Idle && !toggleOnly) finish() }
BackHandler(enabled = !inPip && settingsDisplay == null) { enterPip() }
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center) {
@@ -199,7 +205,7 @@ class PairingGuideActivity : ComponentActivity() {
openSettings(step)
})
SecondaryButton(stringResource(R.string.common_cancel), onClick = {
WirelessAdbPairingService.cancel(this@PairingGuideActivity)
if (!toggleOnly) WirelessAdbPairingService.cancel(this@PairingGuideActivity)
returnToApp()
})
}
@@ -210,6 +216,7 @@ class PairingGuideActivity : ComponentActivity() {
@Composable
private fun instruction(step: Step, stage: Stage): String = when (step) {
Step.BUILD_NUMBER -> stringResource(R.string.pairguide_build_number)
Step.CHILD_PROCESS -> stringResource(R.string.pairguide_child_process)
Step.WIRELESS_DEBUGGING -> stringResource(R.string.pairguide_wireless)
Step.PAIR -> stringResource(R.string.pairguide_pair)
Step.CODE -> (stage as? Stage.CodeNeeded)?.error ?: stringResource(R.string.gate_step3)
@@ -226,6 +233,7 @@ class PairingGuideActivity : ComponentActivity() {
val beat by pulse.animateFloat(0f, 1f, infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Restart), label = "beat")
val label = when (step) {
Step.BUILD_NUMBER -> stringResource(R.string.pairguide_row_build_number)
Step.CHILD_PROCESS -> stringResource(R.string.pairguide_row_child_process)
Step.WIRELESS_DEBUGGING -> stringResource(R.string.pairguide_row_wireless)
Step.PAIR -> stringResource(R.string.pairguide_row_pair)
Step.CODE -> stringResource(R.string.adbpair_enter_code)
@@ -240,7 +248,7 @@ class PairingGuideActivity : ComponentActivity() {
Text(label, style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyLarge,
color = colors.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
when (step) {
Step.WIRELESS_DEBUGGING -> Toggle(on = beat > 0.5f, accent = accent, compact = compact)
Step.WIRELESS_DEBUGGING, Step.CHILD_PROCESS -> Toggle(on = beat > 0.5f, accent = accent, compact = compact)
else -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (step == Step.BUILD_NUMBER) Text("×7", style = MaterialTheme.typography.labelLarge, color = accent)
Box(contentAlignment = Alignment.Center) {
@@ -267,7 +275,8 @@ class PairingGuideActivity : ComponentActivity() {
@Composable
private fun StepDots(step: Step) {
val accent = LocalPalette.current.signal
val shown = listOf(Step.BUILD_NUMBER, Step.WIRELESS_DEBUGGING, Step.PAIR, Step.CODE)
val shown = if (toggleOnly) listOf(Step.BUILD_NUMBER, Step.CHILD_PROCESS)
else listOf(Step.BUILD_NUMBER, Step.WIRELESS_DEBUGGING, Step.PAIR, Step.CODE)
val at = shown.indexOf(step).takeIf { it >= 0 } ?: shown.size
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
shown.forEachIndexed { index, _ ->
@@ -278,22 +287,30 @@ class PairingGuideActivity : ComponentActivity() {
}
}
private data class SettingsState(val developerOptions: Boolean, val wirelessDebugging: Boolean) {
private data class SettingsState(
val developerOptions: Boolean,
val wirelessDebugging: Boolean,
val childProcessBlocked: Boolean,
) {
companion object {
fun read(context: Context) = SettingsState(
GlobalSettings.developerOptionsOn(context),
GlobalSettings.wirelessDebuggingOn(context),
PhantomProcessLimit.blocksSteam(PhantomProcessLimit.read(context)),
)
}
}
companion object {
private const val EXTRA_DISPLAY = "settingsDisplay"
private const val EXTRA_TOGGLE_ONLY = "toggleOnly"
private const val HIGHLIGHT_KEY = ":settings:fragment_args_key"
private fun stepFor(context: Context, stage: Stage) = stepFor(SettingsState.read(context), stage)
private fun stepFor(settings: SettingsState, stage: Stage): Step = when (stage) {
private fun stepFor(settings: SettingsState, stage: Stage, toggleOnly: Boolean): Step = if (toggleOnly) when {
!settings.developerOptions -> Step.BUILD_NUMBER
settings.childProcessBlocked -> Step.CHILD_PROCESS
else -> Step.DONE
} else when (stage) {
is Stage.Working -> Step.WORKING
Stage.Done -> Step.DONE
is Stage.Failed -> Step.FAILED
@@ -317,10 +334,11 @@ class PairingGuideActivity : ComponentActivity() {
return mode == AppOpsManager.MODE_ALLOWED
}
fun start(context: Context, settingsDisplay: Int?): Boolean {
fun start(context: Context, settingsDisplay: Int?, toggleOnly: Boolean = false): Boolean {
if (settingsDisplay == null && !canFloat(context)) return false
context.startActivity(Intent(context, PairingGuideActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOGGLE_ONLY, toggleOnly)
.apply { if (settingsDisplay != null) putExtra(EXTRA_DISPLAY, settingsDisplay) })
return true
}
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,14 @@ object AgentGuest {
private set
var steamKeyboard by mutableStateOf(false)
private set
var desktopTextField by mutableStateOf(false)
private set
var keyboardRequests by mutableIntStateOf(0)
private set
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@Volatile private var routeDir: File? = null
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -35,10 +43,12 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false }
main.post { steamKeyboard = false; desktopTextField = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
routeDir = dir
writeKeyboardRoute(dir, keyboardRoute)
@Suppress("DEPRECATION")
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
@@ -48,6 +58,9 @@ object AgentGuest {
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
}
}
}.also { it.startWatching() }
@@ -58,10 +71,46 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false }
main.post { steamKeyboard = false; desktopTextField = false }
}
}
fun routeKeyboard(context: Context, bottom: Boolean) {
synchronized(lock) {
if (keyboardRoute == bottom) return
keyboardRoute = bottom
writeKeyboardRoute(routeDir ?: dir(context), bottom)
}
}
private fun writeKeyboardRoute(dir: File, bottom: Boolean) {
val file = File(dir, "keyboard-route")
runCatching {
if (!bottom) file.delete()
else if (dir.isDirectory) {
val temp = File(dir, "keyboard-route.tmp")
temp.writeText("bottom")
if (!temp.renameTo(file)) temp.delete()
}
}.onFailure { Log.w(TAG, "keyboard route not written", it) }
}
private fun recordKeyboardRequest(source: FileObserver) {
synchronized(lock) {
if (source !== observer) return
main.post { keyboardRequests++ }
}
SessionEvents.record("keyboard.requested", emptyMap())
}
private fun recordKeyboardDismiss(source: FileObserver) {
synchronized(lock) {
if (source !== observer) return
main.post { keyboardDismissals++ }
}
SessionEvents.record("keyboard.dismissed", emptyMap())
}
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
val showing = snapshot.optBoolean("steamKeyboard")
synchronized(lock) {
@@ -71,6 +120,15 @@ object AgentGuest {
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
}
private fun recordTextField(source: FileObserver, snapshot: JSONObject) {
val focused = snapshot.optBoolean("textField")
synchronized(lock) {
if (source !== observer) return
main.post { desktopTextField = focused }
}
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -23,6 +23,9 @@ class AgentStartActivity : Activity() {
Thread({
try {
AgentBridgeProvider.awaitRecovery()
val until = android.os.SystemClock.elapsedRealtime() + RUNTIME_WAIT_MS
while (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isBusy() && !isFinishing &&
android.os.SystemClock.elapsedRealtime() < until) Thread.sleep(RUNTIME_POLL_MS)
runOnUiThread {
if (!isFinishing) startSession(request)
}
@@ -51,6 +54,11 @@ class AgentStartActivity : Activity() {
}
private fun startSession(request: JSONObject) {
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isBusy()) {
Log.w(TAG, "the Linux runtime is being installed or changed")
finish()
return
}
try {
AgentAccess.requireStart(this, request)
} catch (e: AgentException) {
@@ -107,5 +115,7 @@ class AgentStartActivity : Activity() {
companion object {
const val EXTRA_REQUEST = "request"
private const val TAG = "AgentStartActivity"
private const val RUNTIME_WAIT_MS = 15 * 60_000L
private const val RUNTIME_POLL_MS = 500L
}
}
@@ -146,5 +146,7 @@ internal class AddonPack(
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
val BUTLER = AddonPack("butler", BuildConfig.PACK_BUTLER_SHA256, BuildConfig.PACK_BUTLER_SIZE,
listOf("version", "butler", "7z.so", "libc7zip.so"))
}
}
@@ -29,6 +29,11 @@ import java.util.Locale
object DeviceReport {
private const val TAG = "DeviceReport"
internal fun cpuFeaturesSummary(cpuInfo: String): String = cpuInfo.lineSequence()
.filter { it.substringBefore(':').trim() == "Features" }
.map { it.substringAfter(':').trim().split(Regex("\\s+")).filter(String::isNotEmpty).joinToString(" ") }
.filter(String::isNotEmpty).distinct().joinToString(" | ").ifEmpty { "not reported" }
fun write(context: Context, target: File, mode: String) {
try {
target.writeText(build(context, mode))
@@ -141,6 +146,7 @@ object DeviceReport {
h("CPU and memory")
k("Cores", CpuCores.all.size)
k("CPU features", cpuFeaturesSummary(FileUtils.readString(File("/proc/cpuinfo")).orEmpty()))
b.append("Core ceilings ")
b.append(CpuCores.all.joinToString(", ") { c ->
"cpu$c " + (CpuCores.maxGhz(c)?.let { String.format(Locale.US, "%.2f GHz", it) } ?: "?")
@@ -16,20 +16,83 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public final class Downloader {
private static final String TAG = "Downloader";
private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int STALL_TIMEOUT_MS = 15_000;
private static final int HTTP_RANGE_NOT_SATISFIABLE = 416;
private static final java.util.concurrent.ExecutorService CANCEL_EXECUTOR =
java.util.concurrent.Executors.newCachedThreadPool(r -> {
Thread thread = new Thread(r, "download-cancel");
thread.setDaemon(true);
return thread;
});
private Downloader() {}
public static final class Cancellation implements java.util.function.BooleanSupplier {
private final AtomicBoolean cancelled = new AtomicBoolean();
private final AtomicReference<HttpURLConnection> connection = new AtomicReference<>();
private final AtomicReference<InputStream> input = new AtomicReference<>();
private final java.util.function.BooleanSupplier external;
public Cancellation() { this(null); }
public Cancellation(java.util.function.BooleanSupplier external) { this.external = external; }
public void cancel() {
cancelled.set(true);
HttpURLConnection active = connection.get();
if (active != null) disconnectAsync(active);
InputStream activeInput = input.get();
if (activeInput != null) closeAsync(activeInput);
}
private static void disconnectAsync(HttpURLConnection active) {
CANCEL_EXECUTOR.execute(() -> {
try { active.disconnect(); } catch (RuntimeException ignored) { }
});
}
private static void closeAsync(InputStream active) {
CANCEL_EXECUTOR.execute(() -> {
try { active.close(); } catch (IOException ignored) { }
});
}
@Override public boolean getAsBoolean() {
return cancelled.get() || (external != null && external.getAsBoolean());
}
private void attach(HttpURLConnection active) {
connection.set(active);
if (getAsBoolean()) disconnectAsync(active);
}
private void detach(HttpURLConnection active) { connection.compareAndSet(active, null); }
private void attach(InputStream active) {
input.set(active);
if (getAsBoolean()) closeAsync(active);
}
private void detach(InputStream active) { input.compareAndSet(active, null); }
}
public static String downloadString(String url) {
return downloadString(url, null);
}
public static String downloadString(String url, java.util.function.BooleanSupplier cancelled) {
List<String> sources = DownloadSource.INSTANCE.candidates(url);
for (int i = 0; i < sources.size(); i++) {
if (isCancelled(cancelled)) return null;
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
Fetch fetch = fetch(sources.get(i));
Fetch fetch = fetch(sources.get(i), cancelled);
// A cancelled fetch says nothing about the source, so it must not count as a failure.
if (isCancelled(cancelled)) return null;
if (fetch.body != null) {
DownloadSource.INSTANCE.succeeded(sources.get(i));
return fetch.body;
@@ -53,30 +116,52 @@ public final class Downloader {
/** Verify each candidate before accepting it, so a bad mirror can fall back. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.Consumer<Float> progress) {
return downloadFile(url, destination, resume, expectedSha256, null, progress);
}
/** {@code cancelled} is polled while bytes arrive; a cancel stops at once and leaves the partial file for the caller. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.BooleanSupplier cancelled,
java.util.function.Consumer<Float> progress) {
if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}"))
throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters");
return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, progress);
return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, cancelled, progress);
}
/** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */
static List<String> sources(String url, String expectedSha256) {
return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url);
}
// Package-private for the local multi-source HTTP test.
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
String expectedSha256, java.util.function.Consumer<Float> progress) {
return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress);
}
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
String expectedSha256, java.util.function.BooleanSupplier cancelled,
java.util.function.Consumer<Float> progress) {
Identity identity = loadIdentity(destination, resume, expectedSha256);
for (int i = 0; i < sources.size(); i++) {
String source = sources.get(i);
if (isCancelled(cancelled)) return false;
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, progress);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress);
// A cancel is not a source failure: no other mirror, and no DownloadSource.failed().
if (outcome == Outcome.CANCELLED) return false;
if (outcome == Outcome.DONE) {
if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(Hashes.sha256(destination))) {
Log.w(TAG, "checksum mismatch from " + source + "; trying another source");
if (!discardPartial(destination, identity)) return false;
continue;
if (expectedSha256 != null) {
String actualSha256 = Hashes.sha256(destination);
if (isCancelled(cancelled)) return false;
if (!expectedSha256.equalsIgnoreCase(actualSha256)) {
Log.w(TAG, "checksum mismatch from " + source + "; trying another source");
if (!discardPartial(destination, identity)) return false;
continue;
}
}
if (isCancelled(cancelled)) return false;
resumeFile(destination).delete();
DownloadSource.INSTANCE.succeeded(source);
if (progress != null) progress.accept(1f);
@@ -88,7 +173,11 @@ public final class Downloader {
return false;
}
private enum Outcome { DONE, RETRY, FATAL }
private enum Outcome { DONE, RETRY, FATAL, CANCELLED }
private static boolean isCancelled(java.util.function.BooleanSupplier cancelled) {
return cancelled != null && cancelled.getAsBoolean();
}
private static final class Identity {
private String source;
@@ -156,7 +245,9 @@ public final class Downloader {
}
private static Outcome attempt(String url, File destination, boolean resume, Identity identity,
String expectedSha256, java.util.function.Consumer<Float> progress) {
String expectedSha256, java.util.function.BooleanSupplier cancelled,
java.util.function.Consumer<Float> progress) {
if (isCancelled(cancelled)) return Outcome.CANCELLED;
HttpURLConnection connection = null;
long have = resume && destination.isFile() ? destination.length() : 0;
try {
@@ -166,6 +257,7 @@ public final class Downloader {
have = 0;
}
connection = open(url, STALL_TIMEOUT_MS);
if (cancelled instanceof Cancellation) ((Cancellation) cancelled).attach(connection);
if (have > 0) {
connection.setRequestProperty("Range", "bytes=" + have + "-");
connection.setRequestProperty("If-Range", identity.etag);
@@ -174,7 +266,7 @@ public final class Downloader {
if (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) {
connection.disconnect();
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
return attempt(url, destination, false, identity, expectedSha256, cancelled, progress);
}
if (code / 100 != 2) {
Log.w(TAG, url + " -> HTTP " + code);
@@ -185,13 +277,13 @@ public final class Downloader {
if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) {
connection.disconnect();
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
return attempt(url, destination, false, identity, expectedSha256, cancelled, progress);
}
String etag = connection.getHeaderField("ETag");
if (have > 0 && appending && (!strongETag(etag) || !etag.equals(identity.etag))) {
connection.disconnect();
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
return attempt(url, destination, false, identity, expectedSha256, cancelled, progress);
}
if (!appending && have > 0) {
// If-Range may legitimately return full 200 when representation changed.
@@ -208,26 +300,36 @@ public final class Downloader {
identity.etag = strongETag(etag) ? etag : null;
saveIdentity(destination, identity, expectedSha256);
long written = have;
try (InputStream in = new BufferedInputStream(connection.getInputStream(), 1 << 16);
InputStream input = connection.getInputStream();
if (cancelled instanceof Cancellation) ((Cancellation) cancelled).attach(input);
try (InputStream in = new BufferedInputStream(input, 1 << 16);
OutputStream out = new FileOutputStream(destination, appending)) {
byte[] buffer = new byte[1 << 16];
long lastReport = 0;
for (int read = in.read(buffer); read > 0; read = in.read(buffer)) {
out.write(buffer, 0, read);
written += read;
if (isCancelled(cancelled)) return Outcome.CANCELLED;
if (progress != null && written - lastReport > (1 << 20)) {
lastReport = written;
progress.accept(total > 0 ? written / (float) total : -1f);
}
}
} finally {
if (cancelled instanceof Cancellation) ((Cancellation) cancelled).detach(input);
}
if (isCancelled(cancelled)) return Outcome.CANCELLED;
if (total > 0 && written < total) return Outcome.RETRY;
return Outcome.DONE;
} catch (Exception e) {
if (isCancelled(cancelled)) return Outcome.CANCELLED;
Log.w(TAG, "download " + url, e);
return Outcome.RETRY;
} finally {
if (connection != null) connection.disconnect();
if (connection != null) {
if (cancelled instanceof Cancellation) ((Cancellation) cancelled).detach(connection);
connection.disconnect();
}
}
}
@@ -256,9 +358,15 @@ public final class Downloader {
}
private static Fetch fetch(String url) {
return fetch(url, null);
}
private static Fetch fetch(String url, java.util.function.BooleanSupplier cancelled) {
HttpURLConnection connection = null;
Cancellation token = cancelled instanceof Cancellation ? (Cancellation) cancelled : null;
try {
connection = open(url, STALL_TIMEOUT_MS);
if (token != null) token.attach(connection);
int code = connection.getResponseCode();
if (code / 100 != 2) {
Log.w(TAG, url + " -> HTTP " + code);
@@ -270,10 +378,14 @@ public final class Downloader {
return new Fetch(new String(out.toByteArray(), StandardCharsets.UTF_8), false);
}
} catch (Exception e) {
if (isCancelled(cancelled)) return new Fetch(null, false);
Log.w(TAG, "GET " + url, e);
return new Fetch(null, false);
} finally {
if (connection != null) connection.disconnect();
if (connection != null) {
if (token != null) token.detach(connection);
connection.disconnect();
}
}
}
@@ -1,21 +1,59 @@
package com.droiddeck.launcher.core
object LogRedactor {
private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}")
private val HIDL_SHARED_LIBRARY = Regex(
"(?:android\\.(?:hardware|frameworks|hidl|system)|vendor\\.[A-Za-z0-9_]+\\.hardware)" +
"(?:\\.[A-Za-z0-9_]+)+@[0-9]+\\.[0-9]+(?:-[A-Za-z0-9_]+)*\\.so"
)
private val hidlMatcher = object : ThreadLocal<java.util.regex.Matcher>() {
override fun initialValue(): java.util.regex.Matcher = HIDL_SHARED_LIBRARY.toPattern().matcher("")
internal const val MAX_LINE_LENGTH = 64 * 1024
internal const val OVERSIZED_LINE = "<oversized log line withheld>"
private data class MatcherKey(val pattern: String, val options: Set<RegexOption>)
private class MatcherCache : LinkedHashMap<MatcherKey, java.util.regex.Matcher>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<MatcherKey, java.util.regex.Matcher>?): Boolean =
size > 32
}
private fun isHidlLoaderDiagnostic(input: String, match: MatchResult): Boolean {
val matcher = hidlMatcher.get()!!
val isLibrary = try { matcher.reset(match.value).matches() } finally { matcher.reset("") }
if (!isLibrary) return false
val start = match.range.first
val end = match.range.last + 1
// Android matchers own native state, so reuse them instead of creating one for every log line.
private val matchers = object : ThreadLocal<MatcherCache>() {
override fun initialValue(): MatcherCache = MatcherCache()
}
private fun <T> withMatcher(
regex: Regex,
input: CharSequence,
action: (java.util.regex.Matcher) -> T,
): T {
val key = MatcherKey(regex.pattern, regex.options)
val matcher = matchers.get()!!.getOrPut(key) { regex.toPattern().matcher("") }
return try {
action(matcher.reset(input))
} finally {
matcher.reset("")
}
}
private fun replaceMatches(input: String, regex: Regex, replacement: String): String =
replaceMatches(input, regex) { replacement }
private fun replaceMatches(
input: String,
regex: Regex,
replacement: (java.util.regex.Matcher) -> String,
): String = withMatcher(regex, input) { matcher ->
if (!matcher.find()) return@withMatcher input
val out = StringBuffer(input.length)
do {
matcher.appendReplacement(out, java.util.regex.Matcher.quoteReplacement(replacement(matcher)))
} while (matcher.find())
matcher.appendTail(out)
out.toString()
}
private fun matchesEntire(input: String, regex: Regex): Boolean =
withMatcher(regex, input) { it.matches() }
private fun isHidlLoaderDiagnostic(input: String, match: java.util.regex.Matcher): Boolean {
if (!matchesEntire(match.group(), HIDL_SHARED_LIBRARY)) return false
val start = match.start()
val end = match.end()
val prefix = "library \""
val suffix = "\" not found"
val earliestQuote = (start - 4096).coerceAtLeast(0)
@@ -39,6 +77,12 @@ object LogRedactor {
(suffixEnd == input.length || !(input[suffixEnd].isLetterOrDigit() || input[suffixEnd] == '_'))
}
private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}")
private val HIDL_SHARED_LIBRARY = Regex(
"(?:android\\.(?:hardware|frameworks|hidl|system)|vendor\\.[A-Za-z0-9_]+\\.hardware)" +
"(?:\\.[A-Za-z0-9_]+)+@[0-9]+\\.[0-9]+(?:-[A-Za-z0-9_]+)*\\.so"
)
private val JWT_LABELLED = Regex("(?i)(\\bJWT[\\s=:]+)(\\d{8,})")
private val JWT_BASE64 = Regex("ey[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}")
private val GUID = Regex("\\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\\b")
@@ -111,6 +155,7 @@ object LogRedactor {
java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"),
java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf"),
)
matchers.remove()
}
@Volatile
@@ -178,8 +223,8 @@ object LogRedactor {
}
fun describeAddresses(text: String): String {
var out = IPV6.replace(text) { m -> kind(m.value)?.let { "<$it>" } ?: m.value }
out = IPV4.replace(out) { m -> kind(m.value)?.let { "<$it>" } ?: m.value }
var out = replaceMatches(text, IPV6) { m -> kind(m.group())?.let { "<$it>" } ?: m.group() }
out = replaceMatches(out, IPV4) { m -> kind(m.group())?.let { "<$it>" } ?: m.group() }
return out
}
@@ -204,23 +249,28 @@ object LogRedactor {
"(?<![\\d.])(?:10\\.\\d{1,3}|192\\.168|172\\.(?:1[6-9]|2\\d|3[01])|169\\.254)\\.\\d{1,3}\\.\\d{1,3}(?!\\.?\\d)"
)
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
}
fun scrubTo(
src: java.io.File,
out: java.io.Writer,
shouldStop: () -> Boolean = { false },
): Boolean = scrubLines(src, out, ::redact, shouldStop)
fun redactForShare(line: String): String {
if (line.isEmpty()) return line
if (line.length > MAX_LINE_LENGTH) return OVERSIZED_LINE
return try {
val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
LAN_IPV4.replace(out, "<lan-address>")
replaceMatches(out, LAN_IPV4, "<lan-address>")
} catch (t: Throwable) {
"<redaction failed; line withheld>"
}
}
private const val SHARE_LINE_MAX = 64 * 1024
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
fun scrubForShare(
src: java.io.File,
out: java.io.Writer,
shouldStop: () -> Boolean = { false },
): Boolean {
src.inputStream().buffered().use { input ->
input.mark(2)
val first = input.read()
@@ -228,27 +278,30 @@ object LogRedactor {
input.reset()
if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) {
out.write("<unsupported encoding; file withheld>\n")
return
return true
}
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
val line = StringBuilder(4096)
var withheld: String? = null
var afterCr = false
fun emit() {
out.write(if (withheld) "<unsupported or oversized line withheld>" else redactForShare(line.toString()))
out.write(withheld ?: redactForShare(line.toString()))
out.write("\n")
line.setLength(0)
withheld = false
withheld = null
}
try {
while (true) {
if (shouldStop()) return false
val size = reader.read(buffer)
if (size < 0) break
if (shouldStop()) return false
for (i in 0 until size) {
if (shouldStop()) return false
val ch = buffer[i]
if (afterCr) {
afterCr = false
@@ -257,23 +310,79 @@ object LogRedactor {
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
} else if (withheld == null) {
if (ch == '\u0000' || line.length == MAX_LINE_LENGTH) {
withheld = if (ch == '\u0000') "<unsupported or oversized line withheld>" else OVERSIZED_LINE
line.setLength(0)
} else {
line.append(ch)
}
} else line.append(ch)
}
}
}
if (withheld || line.isNotEmpty()) emit()
if (withheld != null || line.isNotEmpty()) emit()
} catch (_: java.nio.charset.CharacterCodingException) {
out.write("<unsupported encoding; file withheld>\n")
}
}
}
return true
}
private fun scrubLines(
src: java.io.File,
out: java.io.Writer,
scrub: (String) -> String,
shouldStop: () -> Boolean,
): Boolean {
src.bufferedReader().use { input ->
val chunk = CharArray(8192)
val line = StringBuilder(4096)
var oversized = false
var pendingContent = false
var pendingCarriageReturn = false
fun emit() {
out.write(if (oversized) OVERSIZED_LINE else scrub(line.toString()))
out.write("\n")
line.setLength(0)
oversized = false
pendingContent = false
}
while (true) {
if (shouldStop()) return false
val count = input.read(chunk)
if (count < 0) break
if (shouldStop()) return false
for (i in 0 until count) {
val char = chunk[i]
if (pendingCarriageReturn) {
emit()
pendingCarriageReturn = false
if (char == '\n') continue
}
when (char) {
'\r' -> pendingCarriageReturn = true
'\n' -> emit()
else -> {
pendingContent = true
if (!oversized) {
if (line.length < MAX_LINE_LENGTH) line.append(char)
else {
line.setLength(0)
oversized = true
}
}
}
}
}
}
if (pendingCarriageReturn || pendingContent) {
if (shouldStop()) return false
emit()
}
}
return true
}
private fun quoted(value: String): String {
@@ -315,45 +424,51 @@ object LogRedactor {
fun redact(line: String): String {
if (line.isEmpty()) return line
if (line.length > MAX_LINE_LENGTH) return OVERSIZED_LINE
return try {
val out = Line(line)
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
val value = it.groupValues[2]
if (out.has("mac")) out.set(replaceMatches(out.text, MAC_FIELD) { "${it.group(1)}<redacted:mac>" })
if (out.holds(':', 5) || out.holds('-', 5)) out.set(replaceMatches(out.text, MAC, "<redacted:mac>"))
if (out.has("serial", "android")) out.set(replaceMatches(out.text, DEVICE_IDENTIFIER) {
val value = it.group(2)
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
"${it.group(1)}${quote}<redacted:serial>${quote}"
})
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
if (out.holds('-', 4)) out.set(replaceMatches(out.text, GUID, "<redacted:guid>"))
if (out.has("jwt")) out.set(replaceMatches(out.text, JWT_LABELLED) { "${it.group(1)}<redacted:jwt>" })
if ("ey" in out.text) out.set(replaceMatches(out.text, JWT_BASE64, "<redacted:jwt>"))
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>${it.groupValues[2]}" })
out.set(replaceMatches(out.text, SECRET_KV) {
val quote = it.group(2).orEmpty()
"${it.group(1)}${quote}<redacted:token>${quote}"
})
}
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
val id = m.groupValues[1]
if (out.has("guard", "factor", "2fa")) out.set(replaceMatches(out.text, GUARD_CODE) { "${it.group(1)}<redacted:code>" })
if (out.hasRun(32, ::isHex)) out.set(replaceMatches(out.text, WEBAPI_KEY, "<redacted:key>"))
if ("76561" in out.text) out.set(replaceMatches(out.text, STEAMID64) { "${it.group(1)}********${it.group(3)}" })
if ("[U:1:" in out.text) out.set(replaceMatches(out.text, STEAMID3) { m ->
val id = m.group(1)
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
})
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
if (out.has("external")) out.set(replaceMatches(out.text, EXTERNAL_ADDR) { "${it.group(1)}<redacted:ip>" })
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(replaceMatches(out.text, r.regex, "<redacted:ip>"))
if (out.has("onloginstatechange")) out.set(replaceMatches(out.text, LOGIN_STATE) { "${it.group(1)}<redacted:account>" })
if ("OnLoginUsersChanged " in out.text) out.set(replaceMatches(out.text, LOGIN_USERS) { "${it.group(1)}<redacted:account>" })
if (out.has("account", "user")) {
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
out.set(replaceMatches(out.text, ACCOUNT_KV) { "${it.group(1)}${it.group(2)}${quoted(it.group(3))}" })
}
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
if ('@' in out.text) out.set(EMAIL.replace(out.text) { m ->
m.value.takeIf { isHidlLoaderDiagnostic(out.text, m) } ?: "<redacted:email>"
if (out.has("login")) out.set(replaceMatches(out.text, LOGIN_KV) { "${it.group(1)}${it.group(2)}${quoted(it.group(3))}" })
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(replaceMatches(out.text, r.regex, "<redacted:account>"))
if ('@' in out.text) out.set(replaceMatches(out.text, EMAIL) { m ->
m.group().takeIf { isHidlLoaderDiagnostic(out.text, m) } ?: "<redacted:email>"
})
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
out.set(replaceMatches(out.text, RESIDUAL) { "${it.group(1)}=<redacted:token>" })
}
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
out.set(replaceMatches(out.text, LONG_TOKEN, "<redacted:token>"))
}
out.text
} catch (t: Throwable) {
@@ -161,6 +161,25 @@ object AddedGames {
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
)
private val GAME_PARTS = setOf(
"bin", "bin32", "bin64", "binaries", "x64", "x86", "win64", "win32", "windows",
"launcher", "game", "engine", "system", "data", "app",
)
internal fun groupGames(folder: File): List<File>? {
val files = folder.listFiles() ?: return null
if (files.any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) }) return null
if (declaredExes(folder).isNotEmpty()) return null
val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") }.sortedBy { it.name.lowercase() }
if (dirs.size < 2 || dirs.any { it.name.lowercase() in GAME_PARTS }) return null
return dirs.takeIf { all -> all.all { rank(it).exes.isNotEmpty() } }
}
private fun expand(folder: File, entries: Set<String>): List<File> =
if (folder.path in entries) listOf(folder) else groupGames(folder) ?: listOf(folder)
private fun entryFolders(context: Context): Set<String> = AddedExes.list(context).map { it.folder }.toSet()
/** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */
internal fun isSteamLibraryFolder(name: String): Boolean =
name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR
@@ -173,6 +192,7 @@ object AddedGames {
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
val removed = SessionPrefs.removedAddedGames(context).toSet()
val entries = entryFolders(context)
for (dir in folders.distinctBy { it.absolutePath }) {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
@@ -180,7 +200,7 @@ object AddedGames {
if (folder.name.lowercase() in steamInstalls || isSteamLibraryFolder(folder.name)) continue
if (folder.path in removed) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
for (game in expand(folder, entries)) if (game.path !in removed) scanGame(context, game, out)
}
}
for (entry in AddedExes.list(context)) {
@@ -232,8 +252,9 @@ object AddedGames {
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 }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter { owns(it) && groupGames(it) == null }.maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val removed = SessionPrefs.removedAddedGames(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
GameStorage.effective(context)?.let { File(it.path) },
@@ -242,7 +263,10 @@ object AddedGames {
val base = canonical(dir)
if (!inside(path, base)) continue
val first = path.removePrefix("$base/").substringBefore('/')
File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) }?.let { return it }
val top = File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) && it.path !in removed } ?: continue
val group = groupGames(top) ?: return top
if (top.path in entries.map { it.folder }) return null
return group.firstOrNull(::holds)
}
return null
}
@@ -295,10 +319,11 @@ object AddedGames {
if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
val entries = entryFolders(app)
var already = 0
val fresh = ArrayList<String>()
val added = ArrayList<Game>()
for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty()) {
for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty().flatMap { expand(it, entries) }) {
val path = canonical(sub)
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
onChecking(sub.name)
@@ -332,6 +357,7 @@ object AddedGames {
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
val entries = entryFolders(context)
GamesFolderMigration.run(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
@@ -341,7 +367,7 @@ object AddedGames {
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
if (folder.path in removed || isSteamLibraryFolder(folder.name)) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
out.add(canonical(folder))
expand(folder, entries).filter { it.path !in removed }.forEach { out.add(canonical(it)) }
}
}
AddedExes.list(context).filter { it.folder !in removed }.forEach { out.add(canonical(File(it.folder))) }
@@ -89,7 +89,13 @@ object AutoComponents {
private fun enqueue(context: Context, job: Job) {
if (!queuedKeys.add(job.key)) {
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
synchronized(jobs) {
val old = jobs.firstOrNull { it.key == job.key }
if (old != null && ((job.asked && !old.asked) || (job.inSession && !old.inSession)) && jobs.remove(old)) {
jobs.add(Job(old.game ?: job.game, old.component, old.asked || job.asked,
if (job.asked && !old.asked) job.seq else old.seq, old.pics ?: job.pics, old.inSession || job.inSession))
}
}
if (runNow && !draining) drain(context)
return
}
@@ -185,18 +191,19 @@ object AutoComponents {
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return
val wanted = autoPicks(findings, catalog)
val user = WinComponents.selection(context, key).user
val before = WinComponents.selection(context, key).auto
val installed = installedOf(context)
val kept = LinkedHashMap<String, WinComponents.AutoReason>()
val missing = ArrayList<String>()
for ((id, reason) in wanted) {
if (user[id] == false) { kept[id] = reason; continue }
if (id in installed) { kept[id] = reason; continue }
if (id in before) kept[id] = reason
synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } }
queueDownload(context, id, asked, inSession)
missing.add(id)
}
if (inSession) Pending.set(context, key, missing)
val before = WinComponents.selection(context, key).auto
if (inSession) Pending.set(context, key, (Pending.ids(context, key).filter { it !in installed && it !in wanted } + missing).distinct())
if (before.keys != kept.keys) {
WinComponents.setAuto(context, key, kept)
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
@@ -235,6 +242,16 @@ object AutoComponents {
write(context, games)
}
@Synchronized fun ids(context: Context, key: String): List<String> =
read(context).optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
@Synchronized fun add(context: Context, key: String, ids: List<String>) {
val games = read(context)
val have = games.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
games.put(key, JSONArray((have + ids).distinct()))
write(context, games)
}
@Synchronized fun drop(context: Context, id: String) {
val games = read(context)
for (k in games.keys().asSequence().toList()) {
@@ -317,6 +334,32 @@ object AutoComponents {
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
fun restoreMissing(context: Context): List<String> {
val selections = WinComponents.selections(context)
if (selections.isEmpty()) return emptyList()
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return emptyList()
val installed = installedOf(context)
val lostFor = selections.mapValues { (_, selection) ->
withParts(selection.effective.filter { WinComponents.validId(it) }, catalog).filter { it !in installed && it in catalog }
}.filterValues { it.isNotEmpty() }
if (lostFor.isEmpty()) return emptyList()
val lost = lostFor.values.flatten().distinct()
Log.i(TAG, "wincomp: reinstalling ${lost.joinToString()}, picked for games but missing from the runtime")
lostFor.forEach { (key, ids) -> Pending.add(context, key, ids) }
lost.forEach { queueDownload(context, it, asked = true, inSession = true) }
return lost
}
private fun withParts(ids: List<String>, catalog: Map<String, WinComponents.Component>): List<String> {
val out = LinkedHashSet<String>()
fun visit(id: String, depth: Int) {
if (depth > 8 || !out.add(id)) return
catalog[id]?.dependencies?.forEach { visit(it, depth + 1) }
}
ids.forEach { visit(it, 0) }
return out.toList()
}
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
fun sweep(context: Context, games: List<Library.SteamGame>) {
@@ -274,7 +274,7 @@ object Library {
Spec("duckstation", "DuckStation", "PS1", "/opt/appimages/duckstation.AppImage", listOf("ps1", "psx"), setOf("cue", "chd", "pbp", "iso", "bin", "img", "ecm", "m3u")),
Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi"), atPanel = true),
Spec("cemu", "Cemu", "Wii U", "/opt/appimages/cemu.AppImage", listOf("wiiu", "wii u"), setOf("wua", "wud", "wux", "rpx")),
Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")),
Spec("ppsspp", "PPSSPP", "PSP", "/usr/local/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")),
Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()),
)
private fun displayTitle(name: String): String =
@@ -16,6 +16,7 @@ data class GpuInfo(
val modelSource: String = "",
val kgslChipId: String = "",
val platformGpuModel: Int = 0,
val verifiedDevice: Boolean = false,
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
@@ -36,6 +37,7 @@ data class GpuInfo(
get() = when {
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
isA850 -> Support.UNTESTED
verifiedDevice -> Support.TESTED
family == Family.A6XX && model == 650 -> Support.TESTED
family == Family.A8XX -> Support.TESTED
family == Family.A7XX && model >= 725 -> Support.TESTED
@@ -85,9 +87,13 @@ data class GpuInfo(
oneUi8Gen2 = samsung && model == 740,
kgslName = raw.orEmpty(), modelSource = source, kgslChipId = chipId,
platformGpuModel = platformGpuModel,
verifiedDevice = adreno && verifiedOn(model, Build.MODEL),
)
}
internal fun verifiedOn(model: Int, device: String): Boolean =
model == 722 && device.equals("Retroid Pocket Duo", ignoreCase = true)
internal fun threeDigits(text: String): Int = Regex("""(?i)\badreno\s*(?:\(TM\)\s*)?(\d{3})(?!\d)""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
internal fun modelFromNames(
@@ -22,6 +22,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -47,10 +48,16 @@ public final class TurnipDriver {
private final Context context;
private final File contentDir;
private final Supplier<GpuInfo> gpuDetector;
public TurnipDriver(Context context) {
this(context, GpuInfo.Companion::detect);
}
TurnipDriver(Context context, Supplier<GpuInfo> gpuDetector) {
this.context = context;
this.contentDir = new File(context.getFilesDir(), "graphics_driver");
this.gpuDetector = gpuDetector;
}
public String driverPath(String driverId) {
@@ -284,7 +291,12 @@ public final class TurnipDriver {
}
String quest = QuestExperimental.INSTANCE.defaultAndroidDriver();
if (quest != null) return quest;
int model = GpuInfo.Companion.detect().getModel();
GpuInfo gpu = gpuDetector.get();
if (gpu.getFamily() == GpuInfo.Family.NOT_ADRENO) {
Log.i(TAG, "not an Adreno GPU; leaving the display driver to the system");
return null;
}
int model = gpu.getModel();
Log.i(TAG, "gpu model: " + (model == 0 ? "unknown" : model));
return chooseByModel(model, android.os.Build.VERSION.SDK_INT);
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.hud
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
/** artwork/droiddeck-mark.svg: the split D with its circular cut-out and the blue orb. */
internal object DroidDeckMark {
private const val VIEW_X = -55.98f
const val VIEW_W = 239.92f
const val VIEW_H = 223.72f
private val body: Path by lazy {
val d = Path().apply {
moveTo(-31.98f, 0f)
lineTo(63.97f, 0f)
cubicTo(125.81f, 0f, 175.93f, 50.08f, 175.93f, 111.86f)
cubicTo(175.93f, 173.64f, 125.81f, 223.72f, 63.97f, 223.72f)
lineTo(-31.98f, 223.72f)
close()
}
val cut = Path().apply {
addCircle(63.98f, 111.86f, 71.81f, Path.Direction.CW)
addRect(56.055f, 0f, 71.885f, 45f, Path.Direction.CW)
addRect(56.055f, 178.72f, 71.885f, 223.72f, Path.Direction.CW)
}
d.op(cut, Path.Op.DIFFERENCE)
d.offset(-VIEW_X, 0f)
d
}
private val scaled = Path()
private val matrix = Matrix()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
fun widthFor(height: Float): Float = height * VIEW_W / VIEW_H
/** Where the drawn mark starts and ends inside [widthFor]: the artwork has empty space on both sides. */
fun inkLeftFor(height: Float): Float = (-31.98f - VIEW_X) * height / VIEW_H
fun inkRightFor(height: Float): Float = (175.93f - VIEW_X) * height / VIEW_H
fun draw(canvas: Canvas, left: Float, top: Float, height: Float, bodyColor: Int, orbColor: Int) {
val s = height / VIEW_H
matrix.setScale(s, s)
matrix.postTranslate(left, top)
body.transform(matrix, scaled)
paint.color = bodyColor
canvas.drawPath(scaled, paint)
paint.color = orbColor
canvas.drawCircle(left + (63.98f - VIEW_X) * s, top + 111.86f * s, 55.98f * s, paint)
}
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.hud
import android.content.Context
import com.droiddeck.launcher.session.SessionPrefs
/** FusionHUD's settings as one "key=value,…" string (FusionHudView.applyConfig), stored in SessionPrefs. */
internal object FusionHudConfig {
/**
* Bannerlator's Fusion defaults (DeckOverlaySettings), with the FPS graph from the old DroidDeck HUD. The HUD starts
* locked, so like the old HUD it lets touches through until it is unlocked to move it.
*/
val DEFAULTS: Map<String, String> = linkedMapOf(
"hudSize" to "minimal", "hudLocked" to "1", "hudScale" to "100", "hudOpacity" to "80", "hudColor" to "mid",
"hudOutline" to "40", "hudOutlineAccent" to "1",
"showFPS" to "1", "showFPSGraph" to "1", "showCPUUsage" to "1", "showCPULoad" to "1", "showGPULoad" to "1",
"showVram" to "1", "showRAM" to "1", "showPower" to "1", "showTemp" to "1", "showBatteryTemp" to "1",
"showGpuTemp" to "0", "showBattery" to "0", "showLow001" to "1", "fpsDecimal" to "1", "showPerCore" to "1",
"showSwap" to "1", "showNet" to "1", "showResolution" to "1", "showProton" to "1", "showSession" to "1",
"showClock" to "1", "showEngine" to "1", "showRenderer" to "1", "showGpuModel" to "1",
"tempUnit" to "c", "tempBands" to "1", "tempAuto" to "1", "tempRedCpu" to "90", "tempRedGpu" to "90", "tempRedBat" to "48",
)
/** Switches Bannerlator sets together (DeckOverlaySettings twins). */
private val TWINS = mapOf("showCPUUsage" to "showCPULoad", "showTemp" to "showBatteryTemp", "showEngine" to "showRenderer")
fun load(context: Context): MutableMap<String, String> {
val values = LinkedHashMap(DEFAULTS)
val stored = SessionPrefs.fusionHudConfig(context)
if (stored == null) {
values["showFPS"] = flag(SessionPrefs.hudShowFps(context))
values["showFPSGraph"] = flag(SessionPrefs.hudShowGraph(context))
values["hudOpacity"] = SessionPrefs.hudOpacity(context).toString()
} else {
for (entry in KeyValueSet(stored)) if (entry.size == 2 && entry[0] in DEFAULTS) values[entry[0]] = entry[1]
}
return values
}
fun encode(values: Map<String, String>): String = values.entries.joinToString(",") { "${it.key}=${it.value}" }
fun set(context: Context, key: String, value: String): Map<String, String> {
val values = load(context)
if (key !in DEFAULTS) return values
values[key] = value
TWINS[key]?.let { values[it] = value }
SessionPrefs.setFusionHudConfig(context, encode(values))
return values
}
fun reset(context: Context): Map<String, String> {
SessionPrefs.setFusionHudConfig(context, encode(DEFAULTS))
return DEFAULTS
}
private fun flag(on: Boolean) = if (on) "1" else "0"
}
@@ -0,0 +1,236 @@
package com.droiddeck.launcher.hud
import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import kotlin.math.roundToInt
/**
* FusionHUD on the session screen, in place of the old text HUD: the view, the frame feed from gamescope
* (MangoHud's frame times) and MangoHud's readings, started with the session's first frame.
*/
class FusionHudHost(context: Context) {
private val context = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val feed = GameFrameFeed(File(LinuxRuntime.rootDir(this.context), HudFrames.PATH))
private val readings = MangoReadings(File(this.context.filesDir, "session"), LinuxRuntime.rootDir(this.context))
private var view: FusionHudView? = null
private var parent: ViewGroup? = null
private var running = false
private var hidden = false
private var backgrounded = false
private var polling = false
private var wantX = Float.NaN
private var wantY = Float.NaN
private var engine = ""
private var gpuName = ""
private var protonPid = 0
private var proton = ""
@Volatile private var lastWindow: String? = null
var onPresentingWindowChanged: (() -> Unit)? = null
/** The HUD itself changed a setting: a tap cycles the size, a long press locks it. */
var onConfigChanged: (() -> Unit)? = null
/** Screen point → whether an on-screen control takes a touch there; a locked HUD lets those through. */
var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null
private val listener = object : WaylandCompositor.GameListener {
override fun onGameSurface(window: String?, gpuName: String?) {
if (!gpuName.isNullOrBlank()) handler.post { this@FusionHudHost.gpuName = cleanGpuName(gpuName) }
if (window != null && window != lastWindow) {
lastWindow = window
handler.post { onPresentingWindowChanged?.invoke() }
}
}
override fun onGameFrame() {
feed.onCompositorFrame(System.nanoTime())
}
}
private val frameCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!running || !polling) return
if (feed.poll(System.nanoTime())) view?.onFrames()
Choreographer.getInstance().postFrameCallback(this)
}
}
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context) &&
!File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
/** Adds the HUD to [root] at [index] (above the game surface, under the session overlay). */
fun attach(root: FrameLayout, index: Int) {
if (view != null) return
val hud = FusionHudView(root.context)
hud.setFrameStats(feed.stats)
hud.setMangoReadings(readings)
hud.setGpuModel(cleanGpuName(GpuInfo.detect().displayName(context)))
hud.onRefresh = Runnable { refreshLabels(hud) }
hud.setOnMovedListener { x, y ->
wantX = x; wantY = y
keepOnScreen(hud)
wantX = hud.x; wantY = hud.y
SessionPrefs.setFusionHudPosition(context, hud.x, hud.y)
}
hud.setOnSizeCycledListener { size -> FusionHudConfig.set(context, "hudSize", size); onConfigChanged?.invoke() }
hud.setOnLockChangedListener { locked ->
FusionHudConfig.set(context, "hudLocked", if (locked) "1" else "0")
onConfigChanged?.invoke()
}
hud.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (!hud.isDragging) keepOnScreen(hud) }
hud.passesTouchAt = { x, y -> passesTouchAt?.invoke(x, y) == true }
hud.visibility = View.GONE
val margin = (16 * root.resources.displayMetrics.density).roundToInt()
root.addView(hud, index, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = margin
topMargin = margin
})
SessionPrefs.fusionHudPosition(context)?.let { (x, y) -> wantX = x; wantY = y; hud.post { keepOnScreen(hud) } }
view = hud
parent = root
applyConfig()
}
fun applyConfig() {
view?.applyConfig(FusionHudConfig.encode(FusionHudConfig.load(context)))
}
fun refresh() {
applyConfig()
if (!enabled) stop() else if (!running) start()
}
fun start() {
if (running || !enabled || view == null) return
running = true
WaylandCompositor.setGameListener(listener)
applyShown()
}
fun stop() {
running = false
showing = false
polling = false
Choreographer.getInstance().removeFrameCallback(frameCallback)
WaylandCompositor.clearGameListener(listener)
feed.close()
view?.visibility = View.GONE
}
/** Picture-in-picture and similar: keep running, draw nothing. */
fun setHidden(hide: Boolean) {
hidden = hide
applyShown()
}
/** The session screen is stopped (another app in front): draw nothing and stop reading sensors. */
fun setBackgrounded(stopped: Boolean) {
backgrounded = stopped
applyShown()
}
private fun applyShown() {
if (!running) return
val show = !hidden && !backgrounded
view?.visibility = if (show) View.VISIBLE else View.GONE
showing = show
// Frames are read once per display frame only while the HUD is drawn.
if (show != polling) {
polling = show
if (show) Choreographer.getInstance().postFrameCallback(frameCallback)
else Choreographer.getInstance().removeFrameCallback(frameCallback)
}
}
/** A sensor report (FusionHUD's diagnostics) in Downloads/DroidDeck; null when it can't be written. */
fun exportDiagnostics(): File? = runCatching {
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck")
dir.mkdirs()
val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date())
val file = File(dir, "HUD-diagnostics-$stamp.txt")
// Its own reader: the HUD's CPU load is the change since its previous sample, which an export must
// not move. Two samples half a second apart, MangoHud's sampling period.
val metrics = HudMetrics(context)
metrics.setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context)))
metrics.snapshot()
Thread.sleep(500)
val mango = metrics.snapshot()
file.writeText(metrics.also { it.setMangoReadings(null) }.buildDiagnosticsReport(context) + "\n== As the HUD shows them (MangoHud's sources) ==\n" +
"CPU ${mango.cpuPercent}% ${mango.cpuTempC}°C, GPU ${mango.gpuPercent}% ${mango.gpuClockMhz}MHz ${mango.gpuTempC}°C, " +
"RAM ${mango.ramUsedText()}/${mango.ramTotalText()}, VRAM ${mango.vramText()}, battery ${mango.battery.percent}% " +
"${mango.battery.watts}W charging=${mango.battery.charging}\n" +
"CPU temperature zones: ${LinuxRuntime.cpuTempZones().joinToString { it.name }}\n" +
"Frames: ${if (feed.engine.isNotEmpty() || feed.pid > 0) "gamescope (pid ${feed.pid}, ${feed.engine})" else "compositor"}, " +
"fps ${feed.stats.fps}\n")
file
}.getOrNull()
private fun refreshLabels(hud: FusionHudView) {
if (feed.engine.isNotBlank() && !feed.steamFocused) engine = feed.engine
hud.setEngineLabel(engine)
if (gpuName.isNotBlank()) hud.setGpuModel(gpuName)
hud.setResolution(if (feed.outputWidth > 0 && feed.outputHeight > 0) buildString {
append(feed.outputWidth).append('x').append(feed.outputHeight)
if (feed.refreshMilliHz > 0) append(" @ ").append((feed.refreshMilliHz + 499) / 1000).append("Hz")
} else "")
val pid = feed.pid
if (pid > 0 && pid != protonPid) {
protonPid = pid
proton = protonName(pid) ?: proton
}
hud.setWineVersion(proton)
hud.setPresentedFps(presentedFps())
}
private fun presentedFps(): Float {
if (FrameGen.mode(context).engine == FrameGen.ENGINE_OFF) return 0f
val stats = runCatching { WaylandCompositor.nativeFrameGenStats() }.getOrNull() ?: return 0f
return if (stats.size > 3 && stats[3] > 1f) stats[3] else 0f
}
// The HUD is clamped from where the user left it (wantX/wantY, set on a drop or restore), not from where
// it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the
// corner the small window pushed it to.
private fun keepOnScreen(v: View) {
val p = v.parent as? View ?: return
if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return
if (wantX.isNaN()) { wantX = v.x; wantY = v.y }
val maxX = (p.width - v.width).toFloat().coerceAtLeast(0f)
val maxY = (p.height - v.height).toFloat().coerceAtLeast(0f)
val x = wantX.coerceIn(0f, maxX)
val y = wantY.coerceIn(0f, maxY)
if (v.x != x) v.x = x
if (v.y != y) v.y = y
}
companion object {
/** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */
@Volatile @JvmStatic var showing = false
private set
/** "Adreno (TM) 750" as the driver names it, "Adreno 750" as DroidDeck does elsewhere. */
internal fun cleanGpuName(name: String): String = name.replace("(TM)", "").replace(Regex("\\s+"), " ").trim()
/** The Proton build of a game process: the first STEAM_COMPAT_TOOL_PATHS entry's folder. */
internal fun protonName(pid: Int): String? = runCatching {
val environ = File("/proc/$pid/environ").readBytes().toString(Charsets.UTF_8).split('\u0000')
val paths = environ.firstOrNull { it.startsWith("STEAM_COMPAT_TOOL_PATHS=") }?.substringAfter('=') ?: return null
paths.split(':').firstOrNull { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')
}.getOrNull()?.takeIf { it.isNotBlank() }
}
}
@@ -0,0 +1,86 @@
/*
* FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays.
* Copyright (C) The412Banner <https://github.com/The412Banner>
*
* Licensed under the GNU General Public License v3.0 (see LICENSE), with an
* ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md):
* any use, fork, or distribution must preserve credit to "The412Banner" and a
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
*/
package com.droiddeck.launcher.hud
/**
* Size modes for the Fusion HUD (the 4th selectable in-game overlay style). One shared color-coded
* visual language, four amounts of detail — see the approved mockup. The config value is the lower-case
* token stored under the `hudSize` key; the label is the user-facing string.
*/
enum class FusionSize(val token: String, val label: String) {
FULL("full", "Full"),
TILES("tiles", "Tiles"),
PILL("pill", "Pill"),
MINIMAL("minimal", "Minimal"),
MEGA("mega", "Mega");
/** Next size in the tap-cycle: Full → Tiles → Pill → Minimal → Mega → Full. */
fun next(): FusionSize {
val all = values()
return all[(ordinal + 1) % all.size]
}
/**
* The metric toggle chips this size actually renders — the SINGLE source of truth queried by BOTH
* config surfaces ({@code FpsCounterConfigDialog} + in-game {@code XServerDrawer} HudContent) so the
* chip list can't drift from what the view draws. Chip ids are the chip LABELS used in the UI.
* (Hiding a chip is purely UI; buildConfig() still emits every key — strip-invariant.)
*/
fun supportedChips(): Set<String> = when (this) {
MINIMAL -> MINIMAL_CHIPS
PILL -> PILL_CHIPS
FULL, TILES -> STANDARD_CHIPS
MEGA -> MEGA_CHIPS
}
companion object {
fun from(token: String?): FusionSize =
values().firstOrNull { it.token.equals(token, ignoreCase = true) } ?: FULL
// Chip-label ids (must match the labels the config UIs build). "Clock" is a small subtle
// corner readout available in EVERY size, so it's in all sets.
// The API/engine label ("Engine" chip) shows on the FPS row in EVERY size, so it's in all sets.
// "Wrapper" and "DX ver" are Mega-only: the wrapper appears solely below Mega's frametime graph,
// and "DX ver" gates the DX-version suffix on Mega's FPS-row engine label.
val MINIMAL_CHIPS = setOf("FPS", "FPS graph", "0.01% low", "FPS .1", "Engine", "Clock")
val PILL_CHIPS = setOf("FPS", "FPS graph", "GPU", "CPU", "VRAM", "RAM", "Battery", "GPU model", "FPS .1", "Engine", "Clock")
val STANDARD_CHIPS = setOf(
"FPS", "FPS graph", "GPU", "GPU temp", "CPU", "VRAM", "RAM", "Power", "Temp",
"Battery", "GPU model", "Engine", "0.01% low", "FPS .1", "Clock",
)
val MEGA_ONLY = setOf(
"Per-core", "Swap", "Network", "Resolution", "Proton", "Wrapper", "DX ver", "Session",
)
val MEGA_CHIPS = STANDARD_CHIPS + MEGA_ONLY
}
}
/**
* The Wayland HDR state the Fusion HUD shows on its own line, directly under the latency · display-server
* line (the host sets it with [FusionHudView.setHdrState]). [NONE] = no line at all: every session whose
* HDR gate is closed (X11, SDR screens, HDR off) keeps the HUD exactly as it was.
*/
object FusionHdr {
const val NONE = 0
const val ON = 1 // HDR frames on screen with HDR headroom
const val NO_HEADROOM = 2 // HDR frames on screen, but the display gives them no headroom (5 s+)
const val OFF = 3 // the drawer's HDR output switch is off (tone-mapped to SDR)
const val READY = 4 // HDR open for the session, no HDR frames on screen right now
const val TONEMAPPED = 5 // HDR output on, but the frames are shown tone-mapped (frame generation
// on a screen that offers no HDR10 swapchain)
const val NOT_ON_THIS_SCREEN = 6 // HDR open for the session, but the screen the game is on NOW
// reports no HDR10 (the TV was unplugged mid-game). The offer to the
// game cannot be withdrawn once made, so the gate stays open for the
// session — but "ready" would be a lie on a panel that cannot show it.
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,262 @@
package com.droiddeck.launcher.hud
import java.io.File
import kotlin.math.roundToInt
/**
* Frame statistics by MangoHud's rules, so the HUD and Steam's performance overlay agree: MangoHud
* overlay.cpp update_hud_info_with_frametime and hud_elements.cpp fps() / frame_timing(). The FPS is the
* frames counted over a sampling period of at least 500 ms, the frame time next to it is 1000 / FPS, and
* the graph holds the last 200 frame times (zeros until filled) on a fixed 0-50 ms scale, min and max
* taken over those 200. Average and lows are FusionHUD's own readouts.
*/
internal class FrameStats {
private val graph = FloatArray(GRAPH_FRAMES)
private var graphHead = 0
private var lastPresentNs = 0L
private var lastFpsUpdateNs = 0L
private var framesSinceUpdate = 0
private val lowsRing = LongArray(LOWS_FRAMES)
private var lowsHead = 0
private var lowsSize = 0
private var cachedLows = Lows.EMPTY
private var cachedLowsNs = 0L
private var sessionStartNs = 0L
private var sessionFrames = 0L
@Volatile var fps = 0f
private set
/** Completed sampling periods; MangoHud redraws its readouts when one completes. */
@Volatile var fpsUpdates = 0L
private set
/** Frames added so far; the HUD redraws its graph when this moves. */
@Volatile var count = 0L
private set
@Synchronized
fun add(frametimeNs: Long, timeNs: Long) {
if (frametimeNs <= 0L) return
if (lastPresentNs != 0L) {
graph[graphHead] = frametimeNs / 1e6f
graphHead = (graphHead + 1) % GRAPH_FRAMES
}
val elapsed = timeNs - lastFpsUpdateNs
if (elapsed >= FPS_PERIOD_NS) {
fps = (1e9 * framesSinceUpdate / elapsed).toFloat()
framesSinceUpdate = 0
lastFpsUpdateNs = timeNs
fpsUpdates++
}
lastPresentNs = timeNs
framesSinceUpdate++
if (frametimeNs < PAUSE_NS) {
lowsRing[lowsHead] = frametimeNs
lowsHead = (lowsHead + 1) % LOWS_FRAMES
if (lowsSize < LOWS_FRAMES) lowsSize++
if (sessionStartNs == 0L) sessionStartNs = timeNs - frametimeNs
sessionFrames++
}
count++
}
@Synchronized
fun reset() {
graph.fill(0f)
graphHead = 0
lastPresentNs = 0L
lastFpsUpdateNs = 0L
framesSinceUpdate = 0
lowsHead = 0
lowsSize = 0
cachedLows = Lows.EMPTY
cachedLowsNs = 0L
sessionStartNs = 0L
sessionFrames = 0L
fps = 0f
count++
}
/** MangoHud's frame time readout: 1000 / FPS. */
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
/** The graph, oldest first, into [out] (GRAPH_FRAMES long). */
@Synchronized
fun graph(out: FloatArray) {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
}
@Synchronized
fun graphMinMs(): Float = graph.min()
@Synchronized
fun graphMaxMs(): Float = graph.max()
/** Average over the frames this session, from the first frame to [nowNs]'s last frame. */
@Synchronized
fun averageFps(): Float {
if (sessionFrames < 2 || lastPresentNs <= sessionStartNs) return 0f
return (1e9 * sessionFrames / (lastPresentNs - sessionStartNs)).toFloat()
}
@Synchronized
fun sessionSeconds(nowNs: Long): Float = if (sessionStartNs == 0L) 0f else (nowNs - sessionStartNs) / 1e9f
class Lows(val low1Fps: Float, val low01Fps: Float, val low001Fps: Float) {
companion object {
val EMPTY = Lows(0f, 0f, 0f)
}
}
/** FPS at the 99th, 99.9th and 99.99th percentile frame time of the last LOWS_FRAMES frames. */
fun lows(nowNs: Long): Lows {
val copy: LongArray
synchronized(this) {
if (cachedLows !== Lows.EMPTY && nowNs - cachedLowsNs < LOWS_RECOMPUTE_NS) return cachedLows
if (lowsSize < 2) return Lows.EMPTY
copy = LongArray(lowsSize)
val start = (lowsHead - lowsSize + LOWS_FRAMES) % LOWS_FRAMES
for (i in 0 until lowsSize) copy[i] = lowsRing[(start + i) % LOWS_FRAMES]
}
copy.sort()
fun at(p: Double): Float {
val ft = copy[(p * (copy.size - 1)).roundToInt().coerceIn(0, copy.size - 1)]
return if (ft > 0) (1e9 / ft).toFloat() else 0f
}
val result = Lows(at(0.99), at(0.999), at(0.9999))
synchronized(this) {
cachedLows = result
cachedLowsNs = nowNs
}
return result
}
companion object {
const val GRAPH_FRAMES = 200
const val GRAPH_MAX_MS = 50f
const val FPS_PERIOD_NS = 500_000_000L
const val LOWS_FRAMES = 8192
private const val PAUSE_NS = 1_000_000_000L
private const val LOWS_RECOMPUTE_NS = 250_000_000L
}
}
/**
* Feeds [stats] with the frame times gamescope shares (the ones MangoHud shows). Sessions without the
* shared file, or where gamescope stopped sending, fall back to the frames DroidDeck's compositor shows,
* which stop at the screen's refresh rate.
*/
internal class GameFrameFeed(private val file: File, val stats: FrameStats = FrameStats()) {
private var handle = 0L
private var nextOpenNs = 0L
private var lastRingFrameNs = 0L
private var lastInfoNs = 0L
private val frametimes = LongArray(HudFrames.CAPACITY)
private val times = LongArray(HudFrames.CAPACITY)
private val info = IntArray(5)
@Volatile private var fromGamescope = false
private var lastCompositorNs = 0L
@Volatile private var compositorFrames = 0L // written by the compositor thread only
private var seenCompositorFrames = 0L
@Volatile var engine = ""
private set
@Volatile var steamFocused = false
private set
@Volatile var pid = 0
private set
@Volatile var outputWidth = 0
private set
@Volatile var outputHeight = 0
private set
@Volatile var refreshMilliHz = 0
private set
/** Main thread, once per display frame. True when new frames arrived. */
fun poll(nowNs: Long): Boolean {
val compositorAdded = compositorFrames != seenCompositorFrames
seenCompositorFrames = compositorFrames
if (handle == 0L) {
if (nowNs < nextOpenNs) return compositorAdded
nextOpenNs = nowNs + REOPEN_NS
handle = runCatching { HudFrames.nativeOpen(file.path) }.getOrDefault(0L)
if (handle == 0L) return compositorAdded
// Frames already in the file were written before this reader started.
HudFrames.nativePoll(handle, frametimes, times)
}
val n = HudFrames.nativePoll(handle, frametimes, times)
if (n > 0) {
if (!fromGamescope) {
stats.reset()
fromGamescope = true
}
for (i in 0 until n) stats.add(frametimes[i], times[i])
lastRingFrameNs = times[n - 1]
} else if (lastRingFrameNs != 0L && nowNs - lastRingFrameNs > LIVE_NS) {
fromGamescope = false
}
// A new session writes the file again from the start; reopen when gamescope has gone quiet.
if (n <= 0 && nowNs - maxOf(lastRingFrameNs, nextOpenNs - REOPEN_NS) > STALE_NS) close()
if (nowNs - lastInfoNs >= INFO_NS) {
lastInfoNs = nowNs
readInfo()
}
return n > 0 || compositorAdded
}
/** Compositor thread: one frame shown on the panel. */
fun onCompositorFrame(nowNs: Long) {
if (fromGamescope) {
lastCompositorNs = 0L
return
}
val last = lastCompositorNs
lastCompositorNs = nowNs
if (last != 0L) {
stats.add(nowNs - last, nowNs)
compositorFrames++
}
}
fun close() {
if (handle != 0L) HudFrames.nativeClose(handle)
handle = 0L
}
private fun readInfo() {
val name = if (handle != 0L && fromGamescope) HudFrames.nativeInfo(handle, info) else null
if (name == null) {
engine = ""
return
}
pid = info[0]
outputWidth = info[1]
outputHeight = info[2]
refreshMilliHz = info[3]
steamFocused = info[4] != 0
engine = engineLabel(name)
}
companion object {
private const val REOPEN_NS = 2_000_000_000L
private const val LIVE_NS = 2_000_000_000L
private const val STALE_NS = 5_000_000_000L
private const val INFO_NS = 500_000_000L
/** DroidDeck's mangoapp's names for gamescope's engine field (Valve's 20260902.1 build, app/main.cpp
* engine_type_from_str and overlay.h engines); gamescope sends "gamescope" when it knows none. */
fun engineLabel(name: String): String = when (name) {
"DXVK" -> "DXVK"
"vkd3d" -> "VKD3D"
"mesa zink" -> "ZINK"
"Damavand" -> "DAMAVAND"
"Feral3D" -> "Feral3D"
"gamescope", "" -> "GAMESCOPE"
else -> "VULKAN"
}
}
}
@@ -0,0 +1,17 @@
package com.droiddeck.launcher.hud
/** Reader for the frame-times file gamescope patch 0125 writes (hudframes.c). */
internal object HudFrames {
init {
System.loadLibrary("droiddeckwayland")
}
/** Under the Linux root: the session exports /run/droiddeck-hud/frames to gamescope. */
const val PATH = "run/droiddeck-hud/frames"
const val CAPACITY = 1024
@JvmStatic external fun nativeOpen(path: String): Long
@JvmStatic external fun nativeClose(handle: Long)
@JvmStatic external fun nativePoll(handle: Long, frametimes: LongArray, times: LongArray): Int
@JvmStatic external fun nativeInfo(handle: Long, info: IntArray): String?
}
@@ -0,0 +1,265 @@
/*
* FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays.
* Copyright (C) The412Banner <https://github.com/The412Banner>
*
* Licensed under the GNU General Public License v3.0 (see LICENSE), with an
* ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md):
* any use, fork, or distribution must preserve credit to "The412Banner" and a
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
*/
package com.droiddeck.launcher.hud;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.SystemClock;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
/**
* Shared touch + lock behaviour for every in-game performance HUD (classic FrameRating(+Horizontal),
* GameHub PerfHudView, GameNative PerformanceHudView, and the Fusion HUD). One place so the four
* overlays can't drift:
*
* <ul>
* <li><b>tap</b> → {@link Callbacks#onTap()} (orientation-toggle for the first three; size-cycle for
* Fusion) — a no-op that only re-flashes the lock badge while locked.</li>
* <li><b>drag</b> → moves the host view and, on release, {@link Callbacks#onMoved} to persist the
* position — disabled (frozen) while locked.</li>
* <li><b>long-press</b> → toggles {@code hudLocked}, persisted via {@link Callbacks#onLockChanged}
* and kept in sync with the "Lock in place" menu chip. Works locked or unlocked (so a long-press
* always unlocks); a locked {@code FusionHudView} passes touches on only over on-screen controls.</li>
* </ul>
*
* <p>On every lock/unlock (and on a blocked gesture while locked) a small padlock badge fades in →
* holds → fades out in a corner of the panel. The fade is driven by a self-reposting Runnable that
* invalidates only while visible — nothing runs on the present path, nothing animates at rest.
*
* <p>All methods are Main-thread only (touch + draw), matching the epoll-thread rule: the views feed
* this from {@code onTouchEvent}/{@code dispatchDraw}, never from a present tick.
*/
public class HudLockController {
public interface Callbacks {
/** Unlocked single tap: orientation-toggle (classic/gamehub/gamenative) or size-cycle (fusion). */
void onTap();
/** Drag released (unlocked): persist the new (x, y). */
void onMoved(float x, float y);
/** Lock toggled by long-press: persist {@code hudLocked} + sync the menu chip. */
void onLockChanged(boolean locked);
}
// Fade timing (ms): in → hold → out. Lock/unlock hold ~1.5 s; a blocked-gesture re-flash is shorter.
private static final long FADE_IN_MS = 160;
private static final long HOLD_TOGGLE_MS = 1500;
private static final long HOLD_REFLASH_MS = 750;
private static final long FADE_OUT_MS = 420;
private static final long FRAME_MS = 32;
private final View view;
private final Callbacks cb;
private final GestureDetector detector;
private boolean locked = false;
// Drag state
private float downRawX, downRawY, startX, startY;
private boolean dragging = false;
// Fade state
private long fadeStart = 0;
private long holdMs = HOLD_TOGGLE_MS;
private boolean fadeShowLocked = false;
private final Paint iconFill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint iconStroke = new Paint(Paint.ANTI_ALIAS_FLAG);
public HudLockController(Context context, View view, Callbacks cb) {
this.view = view;
this.cb = cb;
this.detector = new GestureDetector(context, new Listener());
this.detector.setIsLongpressEnabled(true);
iconFill.setStyle(Paint.Style.FILL);
iconStroke.setStyle(Paint.Style.STROKE);
iconStroke.setStrokeCap(Paint.Cap.ROUND);
iconStroke.setStrokeJoin(Paint.Join.ROUND);
}
/** Set lock state from config (applyConfig) WITHOUT firing the persist callback. */
public void setLocked(boolean locked) { this.locked = locked; }
public boolean isLocked() { return locked; }
public boolean isDragging() { return dragging; }
/** Feed from the host view's onTouchEvent. Always consumes (mirrors the legacy manual handlers). */
public boolean onTouchEvent(MotionEvent event) {
detector.onTouchEvent(event);
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
if (dragging && !locked) cb.onMoved(view.getX(), view.getY());
dragging = false;
}
return true;
}
private final class Listener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
downRawX = e.getRawX();
downRawY = e.getRawY();
startX = view.getX();
startY = view.getY();
dragging = false;
return true;
}
@Override
public void onLongPress(MotionEvent e) {
// Long-press always toggles — this is the only gesture that still works while locked.
locked = !locked;
cb.onLockChanged(locked);
flash(locked, HOLD_TOGGLE_MS);
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
if (locked) {
flash(true, HOLD_REFLASH_MS); // intentionally locked — remind the user
} else {
cb.onTap();
}
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (locked) {
if (!dragging) flash(true, HOLD_REFLASH_MS);
dragging = true; // mark so the blocked drag doesn't fall through to a tap
return true;
}
view.setX(startX + (e2.getRawX() - downRawX));
view.setY(startY + (e2.getRawY() - downRawY));
dragging = true;
return true;
}
}
// ---- Fade badge -------------------------------------------------------
private void flash(boolean showLocked, long hold) {
fadeShowLocked = showLocked;
holdMs = hold;
fadeStart = SystemClock.elapsedRealtime();
view.removeCallbacks(fadeTick);
view.post(fadeTick);
}
private final Runnable fadeTick = new Runnable() {
@Override
public void run() {
view.invalidate();
if (currentAlpha() > 0f) view.postDelayed(this, FRAME_MS);
}
};
private float currentAlpha() {
if (fadeStart == 0) return 0f;
long e = SystemClock.elapsedRealtime() - fadeStart;
if (e < 0) return 0f;
if (e < FADE_IN_MS) return e / (float) FADE_IN_MS;
long holdEnd = FADE_IN_MS + holdMs;
if (e < holdEnd) return 1f;
long outEnd = holdEnd + FADE_OUT_MS;
if (e < outEnd) return 1f - (e - holdEnd) / (float) FADE_OUT_MS;
return 0f;
}
private static int withAlpha(int color, float a) {
return Color.argb(Math.round(255 * a), Color.red(color), Color.green(color), Color.blue(color));
}
/**
* Draw the padlock badge in the top-right of the panel if a fade is in progress. Call from the
* host's onDraw (plain View) or dispatchDraw (ViewGroup), after the content.
*
* <p>No background box: the glyph is drawn twice — a slightly expanded, thicker semi-transparent
* black "halo" pass for legibility on any game frame, then the white glyph on top — so it reads as
* a floating icon, not an opaque tile.
*/
public void drawBadge(Canvas canvas) {
float a = currentAlpha();
if (a <= 0f) return;
float d = view.getResources().getDisplayMetrics().density;
float size = 15f * d;
float margin = 4f * d;
float right = view.getWidth() - margin;
float left = right - size;
float top = margin;
float cx = (left + right) / 2f;
float bottom = top + size;
float bodyTop = top + size * 0.46f;
float bodyLeft = left + size * 0.14f;
float bodyRight = right - size * 0.14f;
float r = size * 0.2f;
float shackleTop = top + size * 0.08f;
// Pass 1: dark halo (expanded body, thicker shackle) for contrast; Pass 2: white glyph.
drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d,
Color.argb(Math.round(150 * a), 0, 0, 0), 3.0f * d, 0.9f * d, false);
drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d,
withAlpha(0xFFFFFFFF, a), 1.7f * d, 0f, true);
}
/** One padlock pass. {@code halo} inflates the body/shackle in dark; the white pass punches the keyhole. */
private void drawLock(Canvas canvas, float cx, float bodyTop, float bodyLeft, float bodyRight,
float bottom, float r, float shackleTop, float d,
int color, float strokeW, float expand, boolean punchKeyhole) {
iconFill.setColor(color);
iconStroke.setColor(color);
iconStroke.setStrokeWidth(strokeW);
// Body
canvas.drawRoundRect(bodyLeft - expand, bodyTop - expand, bodyRight + expand, bottom + expand,
2.4f * d, 2.4f * d, iconFill);
if (punchKeyhole) {
int prev = iconFill.getColor();
iconFill.setColor(Color.argb(Color.alpha(color), 0, 0, 0));
canvas.drawCircle(cx, (bodyTop + bottom) / 2f, 1.6f * d, iconFill);
iconFill.setColor(prev);
}
RectF arc = new RectF(cx - r, shackleTop, cx + r, shackleTop + 2f * r);
if (fadeShowLocked) {
// Closed: both legs run straight down into the body.
Path p = new Path();
p.addArc(arc, 180f, 180f);
p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop);
p.moveTo(cx + r, shackleTop + r); p.lineTo(cx + r, bodyTop);
canvas.drawPath(p, iconStroke);
} else {
// Open, clearly to the LEFT: the RIGHT leg stays seated in the body; the arc + LEFT leg
// hinge up-and-left off the right attachment so the left leg lifts well clear of the body.
canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke); // fixed right leg
Path p = new Path();
p.addArc(arc, 180f, 180f);
p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); // full-length left leg
canvas.save();
canvas.rotate(-42f, cx + r, shackleTop + r);
canvas.drawPath(p, iconStroke);
canvas.restore();
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,132 @@
/*
* FusionHUD — a lightweight in-game performance HUD for Android / Winlator overlays.
* Copyright (C) The412Banner <https://github.com/The412Banner>
*
* Licensed under the GNU General Public License v3.0 (see LICENSE), with an
* ADDITIONAL attribution requirement under GPL-3.0 §7(b) (see ATTRIBUTION.md):
* any use, fork, or distribution must preserve credit to "The412Banner" and a
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
*/
package com.droiddeck.launcher.hud;
import androidx.annotation.NonNull;
import java.util.Iterator;
public class KeyValueSet implements Iterable<String[]> {
private String data = "";
// Default constructor for an empty KeyValueSet
public KeyValueSet() {
this.data = "";
}
// Constructor to initialize with a string representation of key-value pairs
public KeyValueSet(String data) {
this.data = data != null && !data.isEmpty() ? data : "";
}
// Helper method to find the start and end indices of a key in the data string
private int[] indexOfKey(String key) {
int start = 0;
int end = data.indexOf(",");
if (end == -1) end = data.length();
while (start < end) {
int index = data.indexOf("=", start);
String currKey = data.substring(start, index);
if (currKey.equals(key)) return new int[]{start, end};
start = end + 1;
end = data.indexOf(",", start);
if (end == -1) end = data.length();
}
return null;
}
// Method to get the value associated with a key, or an empty string if the key is not present
public String get(String key) {
for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1];
return "";
}
// Method to get the value associated with a key, or a fallback value if the key is not present
public String get(String key, String fallback) {
for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1];
return fallback;
}
// Method to get a boolean value associated with a key, or a fallback value if the key is not present
public boolean getBoolean(String key, boolean fallback) {
String value = get(key);
if (value.isEmpty()) return fallback;
return value.equals("1") || value.equals("t") || value.equalsIgnoreCase("true");
}
// Method to get a float value associated with a key, or a fallback value if the key is not present
public float getFloat(String key, float fallback) {
String value = get(key);
try {
if (!value.isEmpty()) return Float.parseFloat(value);
} catch (NumberFormatException e) {
// Ignore exception and return fallback
}
return fallback;
}
// Method to set a value for a key in the key-value data
public void put(String key, Object value) {
int[] range = indexOfKey(key);
if (range != null) {
data = data.substring(0, range[0]) + key + "=" + value + data.substring(range[1]);
} else {
data = (!data.isEmpty() ? data + "," : "") + key + "=" + value;
}
}
// Method to set a float value for a key in the key-value data
public void put(String key, float value) {
put(key, String.valueOf(value));
}
// Iterator implementation for key-value pairs in the format [key, value]
@NonNull
@Override
public Iterator<String[]> iterator() {
final int[] start = {0};
final int[] end = {data.indexOf(",")};
// A single-item config (no comma) gives indexOf == -1, making hasNext() (start < end)
// immediately false so the sole pair was never iterated (get() then returned the
// fallback). Guard it exactly as next() already does. An empty string still yields
// end == 0 → no iteration, which is correct.
if (end[0] == -1) end[0] = data.length();
final String[] item = new String[2];
return new Iterator<String[]>() {
@Override
public boolean hasNext() {
return start[0] < end[0];
}
@Override
public String[] next() {
int index = data.indexOf("=", start[0]);
item[0] = data.substring(start[0], index);
item[1] = data.substring(index + 1, end[0]);
start[0] = end[0] + 1;
end[0] = data.indexOf(",", start[0]);
if (end[0] == -1) end[0] = data.length();
return item;
}
};
}
@NonNull
@Override
public String toString() {
return data;
}
}
@@ -0,0 +1,157 @@
package com.droiddeck.launcher.hud
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* The readings Steam's performance overlay shows in a DroidDeck session (Valve's mangohud 20260902.1,
* tools/mangoapp), taken from the files the session hands it and computed the same way, so the two
* overlays agree. Sources and formulas follow that build: cpu.cpp calculateCPUData, deckard_temp.cpp,
* msm.cpp getQcomGpuInfo, memory.cpp update_meminfo and battery.cpp getPercent / getPower.
*/
internal class MangoReadings(private val sessionDir: File, private val rootDir: File) {
class Cpu(val percent: Int, val perCore: IntArray)
class Memory(val usedBytes: Long, val totalBytes: Long, val swapUsedBytes: Long, val swapTotalBytes: Long)
class Battery(val percent: Int?, val watts: Float, val charging: Boolean)
private val cpuTimes = HashMap<String, LongArray>()
private val cpuPercents = HashMap<String, Int>()
/** Total and per-core load from the session's /proc/stat (DroidDeck's stand-in when the app can't read it). */
@JvmOverloads
fun cpu(stat: File = statFile()): Cpu? {
val lines = runCatching { stat.readLines() }.getOrNull() ?: return null
var total: Int? = null
val cores = ArrayList<Int>()
for (line in lines) {
if (!line.startsWith("cpu")) break
val parts = line.split(WHITESPACE)
if (parts.size < 11) continue
val values = LongArray(10) { parts[it + 1].toLongOrNull() ?: 0L }
val percent = cpuPercent(parts[0], values)
if (parts[0] == "cpu") total = percent else cores.add(percent)
}
return total?.let { Cpu(it, cores.toIntArray()) }
}
private fun statFile(): File =
File(sessionDir, "proc-stat").takeIf { !File("/proc/stat").canRead() && it.isFile } ?: File("/proc/stat")
private fun cpuPercent(key: String, v: LongArray): Int {
// user nice system idle iowait irq softirq steal guest guest_nice; guest time is already in user.
val user = v[0] - v[8]
val nice = v[1] - v[9]
val systemAll = v[2] + v[5] + v[6]
val idleAll = v[3] + v[4]
val virt = v[8] + v[9]
val totalTime = user + nice + systemAll + idleAll + v[7] + virt
val now = longArrayOf(user, nice, systemAll, v[7], virt, totalTime)
val last = cpuTimes.put(key, now) ?: LongArray(6)
fun period(i: Int) = if (now[i] > last[i]) now[i] - last[i] else 0L
val totalPeriod = period(5).toFloat()
if (totalPeriod == 0f) return cpuPercents[key] ?: 0
val busy = period(1) * 100f / totalPeriod + period(0) * 100f / totalPeriod +
period(2) * 100f / totalPeriod + (period(3) + period(4)) * 100f / totalPeriod
return busy.coerceIn(0f, 100f).toInt().also { cpuPercents[key] = it }
}
/** Average of the zones the session types cpuN-thermal, truncated. */
fun cpuTempC(): Int? {
var sum = 0f
var count = 0
for (zone in LinuxRuntime.cpuTempZones()) {
val milli = readLong(File(zone, "temp")) ?: continue
sum += milli / 1000f
count++
}
return if (count == 0) null else (sum / count).toInt()
}
fun gpuPercent(): Int? {
val feed = fresh(File(sessionDir, "sys/kgsl-3d0/perf_now"))
val source = feed ?: LinuxRuntime.gpuLoadSource()?.let(::File) ?: return null
return leadingNumber(source)?.toInt()?.coerceIn(0, 100)
}
fun gpuClockMhz(): Int? {
val feed = fresh(File(sessionDir, "sys/kgsl-3d0/cur_freq"))
val source = feed ?: listOf("$KGSL/devfreq/cur_freq", "$KGSL/gpuclk").map(::File).firstOrNull { it.canRead() } ?: return null
val hz = leadingNumber(source) ?: return null
return (hz / 1_000_000.0).toInt().takeIf { it > 0 }
}
fun gpuTempC(): Int? {
val milli = LinuxRuntime.gpuTempSource()?.let { readLong(File(it)) } ?: return null
return (milli / 1000.0).toInt()
}
/** MemTotal - MemAvailable and SwapTotal - SwapFree from /proc/meminfo. */
fun memory(): Memory? {
val kb = HashMap<String, Long>()
runCatching {
File("/proc/meminfo").forEachLine { line ->
val colon = line.indexOf(':')
if (colon > 0) line.substring(colon + 1).trim().split(WHITESPACE).firstOrNull()?.toLongOrNull()
?.let { kb[line.substring(0, colon)] = it }
}
}
val total = kb["MemTotal"] ?: return null
val available = kb["MemAvailable"] ?: return null
val swapTotal = kb["SwapTotal"] ?: 0L
val swapFree = kb["SwapFree"] ?: 0L
return Memory((total - available) * 1024, total * 1024, (swapTotal - swapFree) * 1024, swapTotal * 1024)
}
/** The KGSL total DroidDeck shares with the session (GpuMemComponent). */
fun vramBytes(): Long? = fresh(File(rootDir, VRAM_FILE), VRAM_FRESH_MS)?.let(::readLong)
/** BAT* under the session's power_supply (BatteryComponent): no watts while charging, full or unknown. */
fun battery(): Battery? {
val dirs = File(sessionDir, "sys/power_supply").listFiles { f -> f.name.contains("BAT") }?.sortedBy { it.name }
if (dirs.isNullOrEmpty()) return null
var chargeNow = 0f
var chargeFull = 0f
var watts = 0f
var charging = false
for (dir in dirs) {
val status = runCatching { File(dir, "status").readLines().firstOrNull() }.getOrNull()
if (status == "Charging" || status == "Unknown" || status == "Full") charging = true
val now = File(dir, "charge_now").takeIf { it.exists() } ?: File(dir, "energy_now")
val full = File(dir, "charge_full").takeIf { it.exists() } ?: File(dir, "energy_full")
if (now.exists()) {
chargeNow += (readFloat(now) ?: 0f) / 1_000_000f
chargeFull += (readFloat(full) ?: 0f) / 1_000_000f
} else {
chargeNow += (readFloat(File(dir, "capacity")) ?: 0f) / 100f
chargeFull = dirs.size.toFloat()
}
if (!charging) {
val power = File(dir, "power_now")
watts += if (power.exists()) kotlin.math.abs(readFloat(power) ?: 0f) / 1_000_000f
else kotlin.math.abs((readFloat(File(dir, "current_now")) ?: 0f) * (readFloat(File(dir, "voltage_now")) ?: 0f)) / 1e12f
}
}
val percent = if (chargeFull > 0f) Math.round(chargeNow / chargeFull * 100f) else null
return Battery(percent, if (charging) 0f else watts, charging)
}
private fun fresh(file: File, maxAgeMs: Long = FEED_FRESH_MS): File? =
file.takeIf { it.isFile && System.currentTimeMillis() - it.lastModified() < maxAgeMs }
companion object {
private const val KGSL = "/sys/class/kgsl/kgsl-3d0"
const val VRAM_FILE = "run/droiddeck-hud/gpu-mem"
private const val FEED_FRESH_MS = 10_000L
private const val VRAM_FRESH_MS = 15_000L
private val WHITESPACE = Regex("\\s+")
private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull()
private fun readFloat(file: File): Float? = runCatching { file.readLines().first().trim().toFloat() }.getOrNull()
/** std::stod on the first line: the leading number, "6 %" -> 6. */
fun leadingNumber(file: File): Double? = runCatching {
Regex("^\\s*-?\\d+(\\.\\d+)?").find(file.readLines().first())?.value?.trim()?.toDouble()
}.getOrNull()
}
}
@@ -0,0 +1,118 @@
package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.os.Build
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import com.droiddeck.launcher.R
@SuppressLint("ViewConstructor")
class DesktopKeyboardButton(context: Context, private val onTap: () -> Unit) : View(context) {
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = dp(1.5f) }
private val box = RectF()
private var idleFill = 0
private var idleStroke = 0
private var heldStroke = 0
private var pressed = false
init {
contentDescription = context.getString(R.string.second_show_keyboard)
isClickable = true
visibility = GONE
layoutParams = FrameLayout.LayoutParams(dp(SIZE).toInt(), dp(SIZE).toInt(), Gravity.BOTTOM or Gravity.START)
place(0, 0)
applyColors()
}
private fun dp(value: Float) = value * resources.displayMetrics.density
fun applyColors() {
val settings = ControllerPrefs.read(context)
val tint = settings.tint
val alpha = settings.opacity / 100f
fun light(a: Int, f: Float) = Color.argb(
(a * alpha).toInt(),
(Color.red(tint) + (255 - Color.red(tint)) * f).toInt(),
(Color.green(tint) + (255 - Color.green(tint)) * f).toInt(),
(Color.blue(tint) + (255 - Color.blue(tint)) * f).toInt(),
)
idleFill = Color.argb(190, (Color.red(tint) * 0.12f).toInt(), (Color.green(tint) * 0.12f).toInt(), (Color.blue(tint) * 0.12f).toInt())
idleStroke = light(150, 0.25f)
heldStroke = light(230, 0.7f)
invalidate()
}
private fun place(left: Int, bottom: Int) {
val params = layoutParams as FrameLayout.LayoutParams
val marginStart = left + (dp(CENTER_X) - dp(SIZE) / 2f).toInt()
val marginBottom = bottom + (dp(CENTER_Y) - dp(SIZE) / 2f).toInt()
if (params.leftMargin == marginStart && params.bottomMargin == marginBottom) return
params.leftMargin = marginStart
params.bottomMargin = marginBottom
layoutParams = params
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null
place(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetBottom ?: 0)
return super.onApplyWindowInsets(insets)
}
override fun onDraw(canvas: Canvas) {
val x = width / 2f
val y = height / 2f
fill.color = idleFill
canvas.drawCircle(x, y, dp(18f), fill)
stroke.color = if (pressed) heldStroke else idleStroke
canvas.drawCircle(x, y, dp(18f), stroke)
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
for (row in 0..1) for (col in 0..2) {
val kx = x + dp(-6f + col * 5f)
val ky = y + dp(-3f + row * 4f)
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
}
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { pressed = true; invalidate() }
MotionEvent.ACTION_UP -> {
val inside = event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()
pressed = false
invalidate()
if (inside) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
performClick()
}
}
MotionEvent.ACTION_CANCEL -> { pressed = false; invalidate() }
}
return true
}
override fun performClick(): Boolean {
super.performClick()
onTap()
return true
}
private companion object {
const val SIZE = 52f
const val CENTER_X = 90f
const val CENTER_Y = 34f
}
}
@@ -8,52 +8,125 @@ import kotlin.math.abs
class DirectTouchHold(
private val slopPx: Float,
private val listener: PointerGestures.Listener,
private val cancelTouch: () -> Unit,
private val touch: Touch,
) {
interface Touch {
fun down(pointerId: Int, x: Float, y: Float)
fun move(pointerId: Int, x: Float, y: Float)
fun up(pointerId: Int, x: Float, y: Float)
fun cancel()
}
private val handler = Handler(Looper.getMainLooper())
private var x = 0f
private var y = 0f
private var consumed = false
private var pressed = false
private var downX = 0f
private var downY = 0f
private var downId = 0
private var delayMs = 0L
private var holdBehavior = TouchGestureConfig.MOUSE_BEHAVIOR_CLICK
private var forwarded = false
private var held = false
private var pressing = false
private var upX = 0f
private var upY = 0f
private var liftId = 0
private var liftPending = false
private val lift = Runnable {
liftPending = false
if (forwarded) {
touch.up(liftId, upX, upY)
forwarded = false
}
}
private val release = Runnable {
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
pressed = false
if (pressing) listener.onButton(PointerGestures.BTN_RIGHT, false, downX, downY)
pressing = false
}
private val hold = Runnable {
cancelTouch()
consumed = true
held = true
listener.onLongPress()
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
pressed = true
handler.postDelayed(release, 50L)
listener.onButton(PointerGestures.BTN_RIGHT, true, downX, downY)
pressing = true
if (holdBehavior != TouchGestureConfig.MOUSE_BEHAVIOR_HOLD) handler.postDelayed(release, 50L)
}
fun configure(config: TouchGestureConfig) {
cancel()
delayMs = if (config.longPressEnabled) config.longPressDelay.toLong().coerceAtLeast(1L) else 0L
delayMs = config.longPressDelay.toLong().coerceAtLeast(1L)
holdBehavior = config.directHoldBehavior
}
fun cancel() {
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
handler.removeCallbacks(lift)
liftPending = false
release.run()
consumed = false
if (forwarded) touch.cancel()
forwarded = false
held = false
}
fun onTouch(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
if (liftPending) {
handler.removeCallbacks(lift)
lift.run()
}
cancel()
downX = event.x
downY = event.y
downId = event.getPointerId(0)
if (holdBehavior == TouchGestureConfig.MOUSE_BEHAVIOR_NONE) forwardDown()
else handler.postDelayed(hold, delayMs)
return true
}
if (held) {
when (event.actionMasked) {
MotionEvent.ACTION_UP -> { release.run(); held = false }
MotionEvent.ACTION_CANCEL -> cancel()
}
return true
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
cancel()
x = event.x; y = event.y
if (delayMs > 0L) handler.postDelayed(hold, delayMs)
MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(hold)
if (!forwarded) forwardDown()
val index = event.actionIndex
touch.down(event.getPointerId(index), event.getX(index), event.getY(index))
}
MotionEvent.ACTION_MOVE -> {
if (!forwarded && (event.pointerCount > 1 ||
abs(event.x - downX) > slopPx || abs(event.y - downY) > slopPx)) {
handler.removeCallbacks(hold)
forwardDown()
}
if (forwarded) for (index in 0 until event.pointerCount)
touch.move(event.getPointerId(index), event.getX(index), event.getY(index))
}
MotionEvent.ACTION_POINTER_UP -> {
val index = event.actionIndex
touch.up(event.getPointerId(index), event.getX(index), event.getY(index))
}
MotionEvent.ACTION_UP -> {
handler.removeCallbacks(hold)
if (!forwarded) forwardDown()
upX = event.x
upY = event.y
liftId = event.getPointerId(event.actionIndex)
liftPending = true
handler.postDelayed(lift, LIFT_MS)
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
MotionEvent.ACTION_CANCEL -> cancel()
}
return consumed
return true
}
private fun forwardDown() {
touch.down(downId, downX, downY)
forwarded = true
}
private companion object {
const val LIFT_MS = 50L
}
}
@@ -0,0 +1,81 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.abs
import kotlin.math.roundToInt
object DualScreen {
const val MODE_AUTO = "auto"
const val MODE_ON = "on"
const val MODE_OFF = "off"
val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF)
private const val TYPE_INTERNAL = 1
private val knownModels = setOf("retroid pocket duo", "ayn thor")
private val touchedDisplays = mutableSetOf<Int>()
fun builtInDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? {
val primary = displayManager.getDisplay(primaryDisplayId)
val awake = primary?.state != Display.STATE_OFF
val panels = if (awake) touchPanels() else emptyList()
return internalDisplay(displayManager, primaryDisplayId) {
if (awake && it.state == Display.STATE_OFF) return@internalDisplay false
synchronized(touchedDisplays) {
if (awake && hasOwnTouchPanel(size(it), primary?.let(::size), panels)) touchedDisplays += it.displayId
it.displayId in touchedDisplays
}
}
}
private fun internalDisplay(displayManager: DisplayManager, primaryDisplayId: Int, accept: (Display) -> Boolean = { true }): Display? =
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0 &&
isBuiltIn(displayType(it), Build.MODEL) && accept(it)
}
internal fun hasOwnTouchPanel(display: Pair<Int, Int>, primary: Pair<Int, Int>?, panels: List<Pair<Int, Int>>): Boolean {
val matching = panels.count { covers(it, display) }
return if (primary != null && covers(primary, display)) matching >= 2 else matching >= 1
}
private fun covers(panel: Pair<Int, Int>, display: Pair<Int, Int>): Boolean =
(near(panel.first, display.first) && near(panel.second, display.second)) ||
(near(panel.first, display.second) && near(panel.second, display.first))
private fun near(a: Int, b: Int) = abs(a - b) <= 2
private fun size(display: Display): Pair<Int, Int> =
display.mode.let { it.physicalWidth to it.physicalHeight }
private fun touchPanels(): List<Pair<Int, Int>> =
InputDevice.getDeviceIds().toList().mapNotNull { id ->
val device = InputDevice.getDevice(id) ?: return@mapNotNull null
if (device.isVirtual || !device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN)) return@mapNotNull null
val x = device.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHSCREEN) ?: return@mapNotNull null
val y = device.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHSCREEN) ?: return@mapNotNull null
(x.max - x.min + 1f).roundToInt() to (y.max - y.min + 1f).roundToInt()
}
fun display(context: Context, displayManager: DisplayManager, primaryDisplayId: Int): Display? =
when (SessionPrefs.dualScreenMode(context)) {
MODE_OFF -> null
MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId)
else -> builtInDisplay(displayManager, primaryDisplayId)
}
fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? =
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull {
it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0
}
internal fun isBuiltIn(type: Int?, model: String): Boolean =
if (type != null) type == TYPE_INTERNAL else model.trim().lowercase() in knownModels
private fun displayType(display: Display): Int? =
runCatching { Display::class.java.getMethod("getType").invoke(display) as Int }.getOrNull()
}
@@ -14,6 +14,7 @@ public class FakeInputWriter {
public static final short EV_SYN = 0x00;
public static final short EV_KEY = 0x01;
public static final short EV_REL = 0x02;
public static final short EV_ABS = 0x03;
public static final short EV_MSC = 0x04;
@@ -52,7 +53,8 @@ public class FakeInputWriter {
private static final int EVENT_SIZE = 24;
static final int MAX_EVENTS_PER_UPDATE = 32;
private static final int BUFFER_SIZE = EVENT_SIZE * MAX_EVENTS_PER_UPDATE;
private static final int MAX_FAKE_INPUT_SLOTS = 4;
private static final int MAX_FAKE_INPUT_SLOTS = 5;
public static final int MOUSE_SLOT = 4;
private static final int RING_CAPACITY_EVENTS = 4096;
private static final int RING_HEADER_SIZE = 64;
@@ -88,6 +90,17 @@ public class FakeInputWriter {
public static final int PAD_TYPE_XBOX = 2;
public static final int PAD_TYPE_DS4 = 3;
public static final int PAD_TYPE_DUALSENSE = 4;
public static final int PAD_TYPE_MOUSE = 5;
public static final short REL_X = 0x00;
public static final short REL_Y = 0x01;
public static final short REL_HWHEEL = 0x06;
public static final short REL_WHEEL = 0x08;
public static final short REL_WHEEL_HI_RES = 0x0b;
public static final short REL_HWHEEL_HI_RES = 0x0c;
public static final short MOUSE_BTN_LEFT = 0x110;
public static final short MOUSE_BTN_RIGHT = 0x111;
public static final short MOUSE_BTN_MIDDLE = 0x112;
private static volatile int padType = -1;
private static volatile boolean tritonConnected = false;
private static final int TRITON_OFFSET = RING_SIZE + 64;
@@ -136,6 +149,8 @@ public class FakeInputWriter {
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
private int prevTriggerL, prevTriggerR;
private int prevHatX, prevHatY;
private final boolean[] prevMouseButtons = new boolean[3];
private float pendingMouseX, pendingMouseY;
private boolean hasChanges = false;
private boolean pendingFullResend = false;
private boolean forceResend = false;
@@ -201,7 +216,7 @@ public class FakeInputWriter {
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
}
private static void initializeRingHeader(ByteBuffer data) {
private static void initializeRingHeader(int slot, ByteBuffer data) {
data.order(ByteOrder.LITTLE_ENDIAN);
data.putInt(RING_MAGIC_OFFSET, RING_MAGIC);
data.putInt(RING_VERSION_OFFSET, RING_VERSION);
@@ -221,7 +236,7 @@ public class FakeInputWriter {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
int type = padType;
int type = slot == MOUSE_SLOT ? PAD_TYPE_MOUSE : padType;
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
data.putInt(LATENCY_OFFSET, latency);
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
@@ -312,7 +327,7 @@ public class FakeInputWriter {
raf.setLength(RING_FILE_SIZE);
channel = raf.getChannel();
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
initializeRingHeader(data);
initializeRingHeader(slot, data);
RingSlot ringSlot = new RingSlot();
ringSlot.data = data;
@@ -745,6 +760,14 @@ public class FakeInputWriter {
prevHatY = 0;
writeEvent(EV_ABS, ABS_HAT0Y, 0);
}
for (int i = 0; i < prevMouseButtons.length; i++) {
if (prevMouseButtons[i]) {
prevMouseButtons[i] = false;
writeEvent(EV_KEY, (short) (MOUSE_BTN_LEFT + i), 0);
}
}
pendingMouseX = 0;
pendingMouseY = 0;
if (hasChanges) {
writeEvent(EV_SYN, SYN_REPORT, 0);
@@ -874,4 +897,64 @@ public class FakeInputWriter {
public boolean isOpen() {
return isOpen;
}
public synchronized void writeMouseDelta(float dx, float dy) {
if (!isOpen && !open())
return;
pendingMouseX += dx;
pendingMouseY += dy;
int x = (int) Math.floor(pendingMouseX);
int y = (int) Math.floor(pendingMouseY);
if (x == 0 && y == 0)
return;
pendingMouseX -= x;
pendingMouseY -= y;
buffer.clear();
hasChanges = false;
if (x != 0) writeEvent(EV_REL, REL_X, x);
if (y != 0) writeEvent(EV_REL, REL_Y, y);
writeEvent(EV_SYN, SYN_REPORT, 0);
buffer.flip();
if (!flushBuffer())
Log.e(TAG, "Mouse delta write error: fake input mmap ring unavailable");
}
public synchronized void writeMouseButton(short code, boolean pressed) {
if (code < MOUSE_BTN_LEFT || code > MOUSE_BTN_MIDDLE)
return;
int idx = code - MOUSE_BTN_LEFT;
if (!isOpen && !open())
return;
if (prevMouseButtons[idx] == pressed)
return;
prevMouseButtons[idx] = pressed;
buffer.clear();
hasChanges = false;
writeEvent(EV_KEY, code, pressed ? 1 : 0);
writeEvent(EV_SYN, SYN_REPORT, 0);
buffer.flip();
if (!flushBuffer())
Log.e(TAG, "Mouse button write error: fake input mmap ring unavailable");
}
public synchronized void writeMouseWheel(int verticalClicks, int horizontalClicks, int value120) {
if (verticalClicks == 0 && horizontalClicks == 0)
return;
if (!isOpen && !open())
return;
buffer.clear();
hasChanges = false;
if (verticalClicks != 0) {
writeEvent(EV_REL, REL_WHEEL, verticalClicks);
writeEvent(EV_REL, REL_WHEEL_HI_RES, verticalClicks * value120);
}
if (horizontalClicks != 0) {
writeEvent(EV_REL, REL_HWHEEL, horizontalClicks);
writeEvent(EV_REL, REL_HWHEEL_HI_RES, horizontalClicks * value120);
}
writeEvent(EV_SYN, SYN_REPORT, 0);
buffer.flip();
if (!flushBuffer())
Log.e(TAG, "Mouse wheel write error: fake input mmap ring unavailable");
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.input
import android.os.Build
import android.view.KeyEvent
class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCancel: () -> Unit = {}) {
@@ -8,6 +9,10 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa
fun onKeyEvent(event: KeyEvent, enabled: Boolean): Boolean {
if (!isHome(event.keyCode)) return false
if (isQuickMenuKey(event)) {
if (event.action == KeyEvent.ACTION_DOWN) cancel()
return false
}
if (!enabled) cancel()
val device = event.deviceId
return when (event.action) {
@@ -43,5 +48,10 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa
companion object {
// The Retroid Nova pad falls back to Generic.kl, which maps its Home scan code to MOVE_HOME.
fun isHome(keyCode: Int) = keyCode == KeyEvent.KEYCODE_HOME || keyCode == KeyEvent.KEYCODE_MOVE_HOME
private const val DUO_QUICK_MENU_SCAN_CODE = 194
fun isQuickMenuKey(event: KeyEvent) =
event.scanCode == DUO_QUICK_MENU_SCAN_CODE && "Retroid Pocket Duo".equals(Build.MODEL, ignoreCase = true)
}
}
@@ -23,6 +23,9 @@ class KeyboardHost(
private val map: KeyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
var shown = false
private set
var imeShowing = false
private set
var onImeChanged: ((Boolean) -> Unit)? = null
init {
isFocusable = false
@@ -42,6 +45,28 @@ class KeyboardHost(
return KeyConnection()
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val visible = insets.isVisible(WindowInsets.Type.ime())
if (visible != imeShowing) {
imeShowing = visible
onImeChanged?.invoke(visible)
}
}
return super.onApplyWindowInsets(insets)
}
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || keyCode != KeyEvent.KEYCODE_BACK || !shown) {
return super.onKeyPreIme(keyCode, event)
}
if (event.action == KeyEvent.ACTION_UP && !event.isCanceled) {
hide()
onImeChanged?.invoke(false)
}
return true
}
fun toggle() = if (shown && imeVisible()) hide() else show()
private fun imeVisible() = Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
@@ -672,6 +672,11 @@ class OnScreenControls(
private fun closestSnap(position: Float, targets: List<Float>): Float? =
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
/** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean =
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull {
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
@@ -0,0 +1,145 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
object PhysicalShortcuts {
internal const val KEY = "physicalShortcuts"
private const val TAG = "PhysicalShortcuts"
enum class Action(@StringRes val label: Int) {
STEAM(R.string.drawer_steam_menu),
QAM(R.string.drawer_open_qam),
DROIDDECK(R.string.shortcuts_dd_menu),
}
data class Button(
val device: String,
val name: String,
val keyCode: Int,
val scanCode: Int,
) {
fun matches(event: KeyEvent): Boolean =
event.device?.let { device == identity(it) } == true &&
keyCode == event.keyCode && scanCode == event.scanCode
fun sameInput(other: Button): Boolean =
device == other.device && keyCode == other.keyCode && scanCode == other.scanCode
val label: String
get() = KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_").removePrefix("BUTTON_")
}
data class Binding(val button: Button, val action: Action)
private fun identity(device: InputDevice): String =
"${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
fun isVolumeKey(event: KeyEvent): Boolean = when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> true
else -> false
}
fun isDrawerNavigationKey(event: KeyEvent): Boolean = when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> true
else -> false
}
fun button(event: KeyEvent): Button? {
val device = event.device ?: return null
if (device.isVirtual || isVolumeKey(event)) return null
when (event.keyCode) {
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
}
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
return Button(identity(device), device.name, event.keyCode, event.scanCode)
}
fun read(context: Context): List<Binding> {
val raw = ControllerPrefs.prefs(context).getString(KEY, null) ?: return emptyList()
return try {
val array = JSONArray(raw)
buildList {
for (i in 0 until array.length()) {
try {
val entry = array.getJSONObject(i)
val action = Action.entries.firstOrNull { it.name == entry.getString("action") }
val code = entry.getInt("keyCode")
val scan = entry.getInt("scanCode")
val device = entry.getString("device")
if (action == null || device.isBlank() || code < 0 || scan < 0) {
Log.w(TAG, "Ignoring invalid shortcut at index $i")
continue
}
val binding = Binding(Button(device, entry.getString("name"), code, scan), action)
if (none { it.button.sameInput(binding.button) }) add(binding)
} catch (e: JSONException) {
Log.w(TAG, "Ignoring malformed shortcut at index $i", e)
}
}
}
} catch (e: JSONException) {
Log.w(TAG, "Could not read physical shortcuts; reset shortcuts to restore defaults", e)
emptyList()
}
}
fun assign(context: Context, button: Button, action: Action) {
val bindings = read(context).filterNot {
it.button.sameInput(button) || (it.button.device == button.device && it.action == action)
} + Binding(button, action)
write(context, bindings)
}
fun remove(context: Context, binding: Binding) {
write(context, read(context).filterNot { it == binding })
}
private fun write(context: Context, bindings: List<Binding>) {
val array = JSONArray()
bindings.forEach {
array.put(JSONObject().put("device", it.button.device).put("name", it.button.name)
.put("keyCode", it.button.keyCode).put("scanCode", it.button.scanCode).put("action", it.action.name))
}
ControllerPrefs.prefs(context).edit().putString(KEY, array.toString()).apply()
}
}
class PhysicalShortcutRouter {
var bindings: List<PhysicalShortcuts.Binding> = emptyList()
private data class Press(val device: Int, val keyCode: Int, val scanCode: Int)
private val held = mutableSetOf<Press>()
fun onKey(event: KeyEvent, enabled: Boolean, steam: Boolean, perform: (PhysicalShortcuts.Action) -> Unit): Boolean {
val press = Press(event.deviceId, event.keyCode, event.scanCode)
if (event.action == KeyEvent.ACTION_UP) return held.remove(press)
if (event.action != KeyEvent.ACTION_DOWN) return false
if (press in held) return true
if (!enabled || bindings.isEmpty()) return false
val button = PhysicalShortcuts.button(event) ?: return false
val binding = bindings.firstOrNull { it.button.sameInput(button) } ?: return false
if (!steam && binding.action != PhysicalShortcuts.Action.DROIDDECK) return false
held.add(press)
if (event.repeatCount == 0) perform(binding.action)
return true
}
fun onDeviceRemoved(deviceId: Int) {
held.removeAll { it.device == deviceId }
}
fun clear() {
held.clear()
}
}
@@ -1,104 +1,22 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
import kotlin.math.roundToInt
class PointerGestures(
private val slopPx: Float,
private val listener: Listener,
) {
class PointerGestures {
interface Listener {
fun onMove(x: Float, y: Float)
fun onButton(button: Int, pressed: Boolean, x: Float, y: Float)
fun onWheel(steps: Int)
fun onLongPress()
}
private val handler = Handler(Looper.getMainLooper())
private var downX = 0f
private var downY = 0f
private var downAt = 0L
private var moved = false
private var held = false
private var dragging = false
private var scrolling = false
private var scrollAnchorY = 0f
private val longPress = Runnable {
held = true
listener.onLongPress()
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = event.x; downY = event.y; downAt = event.eventTime
moved = false; held = false; dragging = false; scrolling = false
listener.onMove(event.x, event.y)
handler.postDelayed(longPress, HOLD_MS)
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longPress)
if (dragging) { listener.onButton(BTN_LEFT, false, event.x, event.y); dragging = false }
scrolling = true
scrollAnchorY = event.getY(0)
return true
}
MotionEvent.ACTION_MOVE -> {
if (scrolling) {
val dy = event.getY(0) - scrollAnchorY
val steps = (dy / SCROLL_STEP_PX).toInt()
if (steps != 0) {
listener.onWheel(-steps)
scrollAnchorY += steps * SCROLL_STEP_PX
}
return true
}
if (!moved && (abs(event.x - downX) > slopPx || abs(event.y - downY) > slopPx)) {
moved = true
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(BTN_LEFT, true, downX, downY)
}
}
if (moved) listener.onMove(event.x, event.y)
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
handler.removeCallbacks(longPress)
val cancelled = event.actionMasked == MotionEvent.ACTION_CANCEL
when {
scrolling -> {}
dragging -> listener.onButton(BTN_LEFT, false, event.x, event.y)
held && !moved && !cancelled -> {
listener.onButton(BTN_RIGHT, true, downX, downY)
listener.onButton(BTN_RIGHT, false, downX, downY)
}
!moved && !cancelled && event.eventTime - downAt < TAP_MS -> {
listener.onButton(BTN_LEFT, true, downX, downY)
listener.onButton(BTN_LEFT, false, downX, downY)
}
}
scrolling = false; dragging = false; held = false
return true
}
MotionEvent.ACTION_POINTER_UP -> return true
}
return false
fun onScroll(dx: Float, dy: Float) {}
fun onScrollEnd() {}
fun onZoom(steps: Int) {}
}
companion object {
const val BTN_LEFT = 0x110
const val BTN_RIGHT = 0x111
const val BTN_MIDDLE = 0x112
private const val HOLD_MS = 450L
private const val TAP_MS = 350L
private const val SCROLL_STEP_PX = 40f
fun slop(context: android.content.Context) = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
}
@@ -609,6 +609,9 @@ class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit)
else -> entry.halfH
}
/** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean = editing || entryAt(x, y) != null
private fun entryAt(x: Float, y: Float): Entry? = entries.lastOrNull { entry ->
x >= entry.cx - entry.halfW && x <= entry.cx + entry.halfW && y >= entry.cy - entry.halfH && y <= entry.cy + entry.halfH
}
@@ -86,27 +86,14 @@ class ScrollTouchGestures(
lastY = startY
tracker = VelocityTracker.obtain().also { it.addMovement(event) }
listener.onMove(startX, startY)
handler.postDelayed(longPress, HOLD_MS)
handler.postDelayed(longPress, TouchTiming.HOLD_MS)
}
MotionEvent.ACTION_MOVE -> {
val index = event.findPointerIndex(pointerId).takeIf { it >= 0 } ?: return true
tracker?.addMovement(event)
val x = event.getX(index)
val y = event.getY(index)
if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) {
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true)
} else {
scrolling = true
vertical = abs(y - startY) >= abs(x - startX)
lastX = startX
lastY = startY
}
}
if (dragging) listener.onMove(x, y)
follow(event, x, y)
for (h in 0 until event.historySize)
sample(event.getHistoricalX(index, h), event.getHistoricalY(index, h), event.getHistoricalEventTime(h))
sample(event.getX(index), event.getY(index), event.eventTime)
}
MotionEvent.ACTION_POINTER_UP -> {
if (event.getPointerId(event.actionIndex) == pointerId) cancel()
@@ -115,7 +102,7 @@ class ScrollTouchGestures(
handler.removeCallbacks(longPress)
if (pointerId == -1 || event.getPointerId(0) != pointerId) return true
tracker?.addMovement(event)
follow(event, event.x, event.y)
follow(event.x, event.y, event.eventTime)
when {
scrolling -> tracker?.takeIf { event.eventTime - movedAt <= FLING_STILL_MS }?.let { t ->
t.computeCurrentVelocity(1000)
@@ -139,9 +126,26 @@ class ScrollTouchGestures(
return true
}
private fun follow(event: MotionEvent, x: Float, y: Float) {
private fun sample(x: Float, y: Float, t: Long) {
if (!scrolling && !dragging && hypot(x - startX, y - startY) > slopPx) {
handler.removeCallbacks(longPress)
if (held) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true)
} else {
scrolling = true
vertical = abs(y - startY) >= abs(x - startX)
lastX = startX
lastY = startY
}
}
if (dragging) listener.onMove(x, y)
follow(x, y, t)
}
private fun follow(x: Float, y: Float, t: Long) {
if (!scrolling || (x == lastX && y == lastY)) return
movedAt = event.eventTime
movedAt = t
if (vertical) listener.onScroll(0f, lastY - y) else listener.onScroll(lastX - x, 0f)
lastX = x
lastY = y
@@ -151,7 +155,7 @@ class ScrollTouchGestures(
releaseClick()
listener.onButton(button, true)
clicked = button
handler.postDelayed(clickUp, CLICK_MS)
handler.postDelayed(clickUp, TouchTiming.CLICK_MS)
}
private fun releaseClick() {
@@ -181,8 +185,6 @@ class ScrollTouchGestures(
return if (pixels < 0) -units else units
}
private const val HOLD_MS = 500L
private const val CLICK_MS = 50L
private const val FLING_TIME_S = 0.45f
private const val FLING_STOP_PX = 40f
private const val FLING_STILL_MS = 60L
@@ -9,7 +9,12 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
NONE("none", R.string.second_mode_none),
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
TERMINAL("terminal", R.string.second_mode_terminal),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls);
companion object {
fun opening(remembered: String?, deckPad: Boolean): SecondScreenMode =
entries.firstOrNull { it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) } ?: if (deckPad) DECK_CONTROLS else KEYBOARD_TRACKPAD
}
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -244,6 +244,9 @@ class SteamTouchControls(
private fun hit(list: List<Element>, x: Float, y: Float): Element? =
list.filter { reach(it, x, y) <= 1.15f }.minByOrNull { reach(it, x, y) }
/** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean = editing || menuOpen || hit(live(), x, y) != null
private fun live(): List<Element> =
if (shakenAway) emptyList() else if (options.inputMode == SteamTouchConfig.INPUT_MOUSE) elements.filter { it.type == SteamTouchConfig.THUMB } else elements
@@ -15,6 +15,7 @@ data class TouchGestureConfig(
val longPressAction: String = ACTION_RIGHT_CLICK,
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val longPressDelay: Int = DEFAULT_DELAY_MS,
val directHoldBehavior: String = MOUSE_BEHAVIOR_CLICK,
val doubleTapEnabled: Boolean = true,
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
val twoFingerDragEnabled: Boolean = true,
@@ -50,6 +51,7 @@ data class TouchGestureConfig(
put(KEY_LONG_PRESS_ACTION, longPressAction)
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
put(KEY_LONG_PRESS_DELAY, longPressDelay)
put(KEY_DIRECT_HOLD_BEHAVIOR, directHoldBehavior)
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
@@ -85,6 +87,7 @@ data class TouchGestureConfig(
const val MOUSE_BEHAVIOR_HOLD = "hold"
const val MOUSE_BEHAVIOR_CLICK = "click"
const val MOUSE_BEHAVIOR_NONE = "none"
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
@@ -110,6 +113,7 @@ data class TouchGestureConfig(
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_DIRECT_HOLD_BEHAVIOR = "directHoldBehavior"
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"
@@ -155,6 +159,9 @@ data class TouchGestureConfig(
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
directHoldBehavior = normalizeDirectHoldBehavior(
obj.optString(KEY_DIRECT_HOLD_BEHAVIOR, MOUSE_BEHAVIOR_CLICK)
),
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),
@@ -190,6 +197,10 @@ data class TouchGestureConfig(
}
}
private fun normalizeDirectHoldBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK || value == MOUSE_BEHAVIOR_NONE) value else MOUSE_BEHAVIOR_HOLD
}
private fun normalizeMouseBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.input
import com.droiddeck.launcher.session.SessionPrefs
object TouchRouter {
enum class Route { BLOCKED, GESTURES, SCROLL, OFF, TOUCHPAD, DIRECT }
data class Snapshot(
val uiBlocking: Boolean,
val mode: String,
val scrollLayer: Boolean,
val steamSession: Boolean,
val steamDesktopUi: Boolean,
)
fun decide(s: Snapshot): Route {
if (s.uiBlocking) return Route.BLOCKED
if (s.mode == SessionPrefs.TOUCH_GESTURES) return Route.GESTURES
if (s.scrollLayer) return Route.SCROLL
if (s.mode == SessionPrefs.TOUCH_OFF) return Route.OFF
return if (touchpad(s)) Route.TOUCHPAD else Route.DIRECT
}
fun steamKeyboardZone(s: Snapshot): Boolean = s.steamSession && !s.steamDesktopUi
private fun touchpad(s: Snapshot): Boolean = when (s.mode) {
SessionPrefs.TOUCH_PAD -> true
SessionPrefs.TOUCH_DIRECT -> false
else -> !s.steamSession || s.steamDesktopUi
}
}
@@ -0,0 +1,9 @@
package com.droiddeck.launcher.input
object TouchTiming {
const val HOLD_MS = 500L
const val TAP_MS = 300L
const val CLICK_MS = 30L
const val DOUBLE_TAP_MS = 300L
const val SCROLL_STEP_PX = 36f
}
@@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import kotlin.math.abs
import kotlin.math.hypot
class TouchpadGestures(
private val slopPx: Float,
@@ -15,6 +16,7 @@ class TouchpadGestures(
private set
var bounds: android.graphics.RectF = android.graphics.RectF(0f, 0f, 1f, 1f)
var sensitivity = 1.15f
var desktop = false
private val handler = Handler(Looper.getMainLooper())
private var lastX = 0f
@@ -27,7 +29,11 @@ class TouchpadGestures(
private var secondDownAt = 0L
private var secondWasTap = false
private var twoFingers = false
private var scrollAnchorX = 0f
private var scrollAnchorY = 0f
private var fingers = 0
private var pinching = false
private var pinchSpan = 0f
private var scrolled = false
private var anchorId = -1
private var anchorStartX = 0f
@@ -52,7 +58,7 @@ class TouchpadGestures(
releaseClick()
listener.onButton(button, true, x, y)
clicked = button
handler.postDelayed(clickUp, CLICK_MS)
handler.postDelayed(clickUp, TouchTiming.CLICK_MS)
}
private fun releaseClick() {
@@ -79,6 +85,20 @@ class TouchpadGestures(
y = py.coerceIn(bounds.top, bounds.bottom)
}
private fun trackMove(px: Float, py: Float) {
val dx = px - lastX
val dy = py - lastY
lastX = px; lastY = py
travelled += abs(dx) + abs(dy)
if (travelled > slopPx) {
handler.removeCallbacks(longPress)
val speed = abs(dx) + abs(dy)
val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f))
place(x + dx * gain, y + dy * gain)
listener.onMove(x, y)
}
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
@@ -86,18 +106,20 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
fingers = 1; pinching = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
if (event.eventTime - lastTapAt < TouchTiming.DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
} else {
dragging = false
handler.postDelayed(longPress, HOLD_MS)
handler.postDelayed(longPress, TouchTiming.HOLD_MS)
}
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longPress)
fingers = maxOf(fingers, event.pointerCount)
val i = event.actionIndex
if (moverId == -1 && event.getPointerId(i) != anchorId) {
moverId = event.getPointerId(i)
@@ -111,7 +133,9 @@ class TouchpadGestures(
twoFingers = true
secondDownAt = event.eventTime
secondWasTap = true
scrollAnchorX = (event.getX(0) + event.getX(1)) / 2f
scrollAnchorY = (event.getY(0) + event.getY(1)) / 2f
pinchSpan = span(event)
return true
}
MotionEvent.ACTION_MOVE -> {
@@ -146,29 +170,49 @@ class TouchpadGestures(
} else if (holdDrag) {
return true
}
if (twoFingers && event.pointerCount >= 2) {
val mid = (event.getY(0) + event.getY(1)) / 2f
val dy = mid - scrollAnchorY
val steps = (dy / SCROLL_STEP_PX).toInt()
if (steps != 0) {
listener.onWheel(-steps)
scrollAnchorY += steps * SCROLL_STEP_PX
if (twoFingers && event.pointerCount >= 2 && desktop) {
if (fingers >= 3) return true
val span = span(event)
if (!scrolled && !pinching && abs(span - pinchSpan) > slopPx * PINCH_SLOPS) {
pinching = true
secondWasTap = false
pinchSpan = span
}
if (pinching) {
val steps = ((span - pinchSpan) / PINCH_STEP_PX).toInt()
if (steps != 0) {
listener.onZoom(-steps)
pinchSpan += steps * PINCH_STEP_PX
}
return true
}
val midX = (event.getX(0) + event.getX(1)) / 2f
val midY = (event.getY(0) + event.getY(1)) / 2f
val dx = scrollAnchorX - midX
val dy = scrollAnchorY - midY
if (scrolled || abs(dx) + abs(dy) > slopPx) {
listener.onScroll(dx, dy)
scrollAnchorX = midX; scrollAnchorY = midY
scrolled = true
secondWasTap = false
}
return true
}
val dx = event.x - lastX
val dy = event.y - lastY
lastX = event.x; lastY = event.y
travelled += abs(dx) + abs(dy)
if (travelled > slopPx) {
handler.removeCallbacks(longPress)
val speed = (abs(dx) + abs(dy))
val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f))
place(x + dx * gain, y + dy * gain)
listener.onMove(x, y)
if (twoFingers && event.pointerCount >= 2) {
val mid = (event.getY(0) + event.getY(1)) / 2f
val dy = mid - scrollAnchorY
val steps = (dy / TouchTiming.SCROLL_STEP_PX).toInt()
if (steps != 0) {
listener.onWheel(-steps)
scrollAnchorY += steps * TouchTiming.SCROLL_STEP_PX
scrolled = true
secondWasTap = false
}
return true
}
for (h in 0 until event.historySize)
trackMove(event.getHistoricalX(0, h), event.getHistoricalY(0, h))
trackMove(event.x, event.y)
return true
}
MotionEvent.ACTION_POINTER_UP -> {
@@ -194,7 +238,12 @@ class TouchpadGestures(
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) {
if (desktop && scrolled && event.pointerCount == 2) listener.onScrollEnd()
if (fingers >= 3) {
if (event.pointerCount == 2 && secondWasTap && !scrolled && event.eventTime - downAt < TouchTiming.TAP_MS * 2)
click(PointerGestures.BTN_MIDDLE)
if (event.pointerCount == 2) secondWasTap = false
} else if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TouchTiming.TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
secondWasTap = false
}
@@ -206,29 +255,30 @@ class TouchpadGestures(
when {
dragging -> listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
twoFingers -> {
if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TAP_MS * 2) {
if (secondWasTap && !scrolled && !cancelled && event.eventTime - downAt < TouchTiming.TAP_MS * 2) {
click(PointerGestures.BTN_RIGHT)
}
}
!cancelled && travelled <= slopPx && event.eventTime - downAt < TAP_MS -> {
!cancelled && travelled <= slopPx && event.eventTime - downAt < TouchTiming.TAP_MS -> {
click(PointerGestures.BTN_LEFT)
lastTapAt = event.eventTime
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
pinching = false; fingers = 0
return true
}
}
return false
}
private fun span(event: MotionEvent): Float =
if (event.pointerCount < 2) 0f else hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
companion object {
private const val HOLD_MS = 500L
private const val TAP_MS = 300L
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val PINCH_SLOPS = 3f
private const val PINCH_STEP_PX = 60f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
@@ -36,7 +36,7 @@ public final class TouchscreenGestures {
private final Output output;
private final float doubleTapSlop;
private TouchGestureConfig gestureConfig = new TouchGestureConfig();
private static final int CLICK_DELAYED_TIME = 50;
private static final int CLICK_DELAYED_TIME = 30;
private Runnable delayedPress, pendingHoldClickRelease;
private Runnable pendingTapPress;
private final ArrayDeque<TapClick> pendingTapClicks = new ArrayDeque<>();
@@ -180,7 +180,8 @@ object BwrapSpawner {
val env = LinkedHashMap<String, String>()
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
val libc = listOf("/usr/lib/aarch64-linux-gnu/libc.so.6", "/lib/aarch64-linux-gnu/libc.so.6").map { hostPath(binds, root.path, it) }
if (preloadFits(rootfs, libc.firstOrNull { File(it).isFile } ?: libc.first())) preload(session, rootfs, env).forEach { addBind(it) }
zypakShims(binds, root, dir).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
@@ -188,16 +189,20 @@ object BwrapSpawner {
private const val GPU_DIR = "/run/droiddeck-gpu"
private val LIB_DIRS = listOf("usr/lib/aarch64-linux-gnu", "usr/lib")
private val GPU_LIBS = listOf("libdisplay-info.so.3", "libdisplay-info.so.2", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")
private fun runtimeLib(rootfs: String, name: String): File? =
LIB_DIRS.map { File(rootfs, "$it/$name") }.firstOrNull { it.exists() }
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
val lib = File(rootfs, "usr/lib")
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
val driver = runtimeLib(rootfs, "libvulkan_freedreno.so")
if (driver == null || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
if (env["BL_DESKTOP"] == "1" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
val binds = arrayListOf(Bind(driver.canonicalPath, "$GPU_DIR/libvulkan_freedreno.so"))
for (name in GPU_LIBS) {
val f = runtimeLib(rootfs, name) ?: continue
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
}
val icd = File(root.parentFile, "freedreno_icd.json")
@@ -227,6 +232,27 @@ object BwrapSpawner {
return binds
}
private val GLIBC_RELEASE = Regex("release version 2\\.(\\d+)")
private val GLIBC_NEED = Regex("GLIBC_2\\.(\\d+)")
private val glibcMinors = java.util.concurrent.ConcurrentHashMap<String, Int>()
private fun glibcMinor(file: File, pattern: Regex, pick: (Sequence<Int>) -> Int?): Int? {
val key = "${file.path}:${file.length()}:${file.lastModified()}:${pattern.pattern}"
glibcMinors[key]?.let { return it }
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: return null
return pick(pattern.findAll(text).map { it.groupValues[1].toInt() })?.also { glibcMinors[key] = it }
}
internal fun preloadFits(rootfs: String, sandboxLibc: String): Boolean {
val libc = File(sandboxLibc)
if (!libc.isFile) return true
val have = glibcMinor(libc, GLIBC_RELEASE) { it.firstOrNull() } ?: return true
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
?.map { it.trim() }?.filter { it.startsWith("/") } ?: return true
val need = libs.mapNotNull { File(rootfs + it).takeIf { f -> f.isFile }?.let { f -> glibcMinor(f, GLIBC_NEED) { m -> m.maxOrNull() } } }.maxOrNull() ?: return true
return have >= need
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
private const val NET_LINK = "etc/droiddeck-net"
@@ -321,7 +347,15 @@ object BwrapSpawner {
catch (e: IOException) { }
if (process.isAlive) {
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
process.destroy()
if (pid > 0) {
runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) }
} else {
process.destroy()
}
try {
if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly()
} catch (_: InterruptedException) {
}
}
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
val buffer = ByteArray(16384)
@@ -22,8 +22,8 @@ object DesktopCatalog {
val icon: String, val category: String,
)
fun fetch(url: String = CATALOG_URL): List<Entry>? {
val body = Downloader.downloadString(url) ?: return null
fun fetch(url: String = CATALOG_URL, cancelled: java.util.function.BooleanSupplier? = null): List<Entry>? {
val body = Downloader.downloadString(url, cancelled) ?: return null
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
@@ -71,9 +71,22 @@ object DesktopCatalog {
}
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
resumeInterrupted(context, null, listener)
}
fun resumeInterrupted(
context: Context,
cancelled: Downloader.Cancellation?,
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 }
val problem = if (cancelled == null) {
install(context, entry, listener)
} else {
install(context, entry, cancelled, listener)
}
if (problem != null) return problem
}
null
}
@@ -102,19 +115,27 @@ object DesktopCatalog {
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
const val DESKTOP_ID = "desktop-kde"
const val EMULATORS_ID = "emulators"
private const val EMULATORS_SCRIPT = "/usr/local/bin/droiddeck-emulators"
fun desktopEntry(context: Context) = Entry(
DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, BuildConfig.DESKTOP_KDE_TAG, "tar",
BuildConfig.DESKTOP_KDE_URL, BuildConfig.DESKTOP_KDE_SHA256, BuildConfig.DESKTOP_KDE_SIZE, "",
"", "",
)
fun desktopEntry(context: Context): Entry {
val pack = DesktopPack.info(LinuxRuntime.rootDir(context))
return Entry(
DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, pack?.tag ?: BuildConfig.DESKTOP_KDE_TAG, "tar",
pack?.url ?: BuildConfig.DESKTOP_KDE_URL, pack?.sha256 ?: BuildConfig.DESKTOP_KDE_SHA256,
pack?.size ?: BuildConfig.DESKTOP_KDE_SIZE, "", "", "",
)
}
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
fun desktopFromPack(root: File): Boolean = DesktopPack.info(root) != null
fun desktopInstalled(root: File): Boolean =
!File(root, DesktopRemoval.PENDING).exists() &&
File(root, "usr/bin/kwin_wayland").isFile &&
FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() == BuildConfig.DESKTOP_KDE_TAG
FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() ==
(DesktopPack.info(root)?.tag ?: BuildConfig.DESKTOP_KDE_TAG)
fun desktopPresent(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
@@ -144,12 +165,19 @@ object DesktopCatalog {
}
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, listener)
installLocked(context, entry, null, listener)
}
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, cancelled: Downloader.Cancellation,
listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, cancelled, listener)
}
private fun installLocked(context: Context, entry: Entry, cancelled: Downloader.Cancellation?,
listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.id == EMULATORS_ID && desktopFromPack(root)) return installEmulators(context, entry, cancelled, listener)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
@@ -166,14 +194,20 @@ object DesktopCatalog {
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 ->
val progress = java.util.function.Consumer<Float> { 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)
val ok = if (cancelled == null) {
Downloader.downloadFile(entry.url, download, true, entry.sha256, progress)
} else {
Downloader.downloadFile(entry.url, download, true, entry.sha256, cancelled, progress)
}
if (!ok || cancelled?.getAsBoolean() == true) 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)
}
if (cancelled?.getAsBoolean() == true) return context.getString(R.string.user_apps_download_failed)
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
@@ -192,11 +226,12 @@ object DesktopCatalog {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (entry.id == DESKTOP_ID && desktopFromPack(root)) DesktopPack.prepareInstall(root)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, report)
if (desktopFromPack(root)) DesktopPack.finishInstall(root) else cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
@@ -214,6 +249,47 @@ object DesktopCatalog {
}
}
private fun installEmulators(context: Context, entry: Entry, cancelled: Downloader.Cancellation?,
listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val stop = java.util.concurrent.atomic.AtomicBoolean(false)
val finished = java.util.concurrent.atomic.AtomicBoolean(false)
cancelled?.let { token ->
Thread({
try {
while (!finished.get() && !stop.get()) {
if (token.getAsBoolean()) stop.set(true) else Thread.sleep(200)
}
} catch (_: InterruptedException) {
}
}, "emulators-cancel").apply { isDaemon = true; start() }
}
try {
var failure: String? = null
val status = GuestCommand.run(context, listOf("/bin/bash", EMULATORS_SCRIPT, "install"), logName = "emulators",
cancelled = stop) { line ->
when {
line.startsWith("== STEP ") -> {
val stage = context.getString(R.string.deskpkg_installing, line.removePrefix("== STEP "))
RuntimeInstallService.packageProgress(stage, -1)
listener?.onProgress(stage, -1)
}
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
}
}
if (stop.get()) return context.getString(R.string.user_apps_download_failed)
if (failure != null || status != 0) return context.getString(R.string.deskpkg_install_failed)
check(FileUtils.writeString(marker(context, entry.id), entry.version)) { "Could not finish package installation" }
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
finished.set(true)
release()
}
}
private fun unzipAppImage(zip: File, target: File): Boolean {
try {
java.util.zip.ZipInputStream(java.io.BufferedInputStream(java.io.FileInputStream(zip))).use { zin ->
@@ -0,0 +1,178 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.zip.GZIPInputStream
import org.json.JSONObject
internal object DesktopPack {
const val INFO = "usr/share/droiddeck/desktop-pack.json"
const val META = "usr/share/droiddeck/desktop"
private const val MANIFEST = "$META/files.tsv.gz"
private const val ORIGINALS = "$META/originals"
private const val STATUS = "var/lib/dpkg/status"
private const val STATUS_BEFORE = "var/lib/dpkg/status.droiddeck"
private const val PACK_STATUS = "$META/dpkg-status"
data class Info(val tag: String, val url: String, val sha256: String, val size: Long)
data class Entry(val kind: Char, val type: Char, val size: Long, val value: String, val path: String) {
companion object {
fun parse(line: String): Entry? {
val parts = line.split('\t', limit = 5)
if (parts.size != 5 || parts[0].length != 1 || parts[1].length != 1) return null
return Entry(parts[0][0], parts[1][0], parts[2].toLongOrNull() ?: return null, parts[3], parts[4])
}
}
}
data class Result(val removed: Int, val restored: Int, val kept: Int)
fun info(root: File): Info? = runCatching {
val json = JSONObject(FileUtils.readString(File(root, INFO)) ?: return null)
Info(json.getString("tag"), json.getString("url"), json.getString("sha256"), json.getLong("size"))
}.getOrNull()
fun installed(root: File): Boolean = File(root, MANIFEST).isFile
fun entries(root: File): List<Entry> =
GZIPInputStream(File(root, MANIFEST).inputStream()).bufferedReader().use { reader ->
reader.lineSequence().mapNotNull(Entry::parse).toList()
}
fun installedSize(root: File): Long =
if (installed(root)) entries(root).filter { it.kind == 'N' && it.type == 'F' }.sumOf { it.size } else 0L
fun prepareInstall(root: File) {
val live = File(root, STATUS)
val before = File(root, STATUS_BEFORE)
if (!before.exists() && live.isFile) Files.copy(live.toPath(), before.toPath(), StandardCopyOption.COPY_ATTRIBUTES)
}
fun finishInstall(root: File) {
for (entry in entries(root).filter { it.kind == 'R' }.sortedByDescending { it.path.length }) {
val file = inside(root, entry.path) ?: continue
if (entry.type == 'D') file.delete() else Files.deleteIfExists(file.toPath())
}
val live = File(root, STATUS)
val packStatus = File(root, PACK_STATUS)
if (live.isFile && !packStatus.exists()) Files.copy(live.toPath(), packStatus.toPath())
val before = File(root, STATUS_BEFORE)
val runtime = File(File(root, ORIGINALS), STATUS)
if (before.isFile && runtime.isFile && live.isFile) {
writeStatus(live, mergeStatus(runtime.readText(), packStatus.readText(), before.readText()))
}
Files.deleteIfExists(before.toPath())
}
internal fun mergeStatus(base: String, target: String, mine: String): String {
val from = stanzas(base)
val into = LinkedHashMap(stanzas(target))
val kept = stanzas(mine)
for ((key, stanza) in kept) {
if (from[key] != stanza && into[key] == from[key]) into[key] = stanza
}
for (key in from.keys) {
if (key !in kept && into[key] == from[key]) into.remove(key)
}
return into.values.joinToString("") { it + "\n\n" }
}
private fun stanzas(text: String): Map<String, String> {
val out = LinkedHashMap<String, String>()
for (stanza in text.split(Regex("\n[ \t]*\n")).map { it.trim('\n') }.filter { it.isNotBlank() }) {
val fields = stanza.lines()
fun field(name: String) = fields.firstOrNull { it.startsWith("$name:") }?.substringAfter(':')?.trim().orEmpty()
out["${field("Package")}:${field("Architecture")}"] = stanza
}
return out
}
private fun writeStatus(file: File, text: String) {
val part = LinuxRuntimeInstaller.partFile(file).toPath()
Files.deleteIfExists(part)
Files.copy(file.toPath(), part, StandardCopyOption.COPY_ATTRIBUTES)
Files.write(part, text.toByteArray(), java.nio.file.StandardOpenOption.TRUNCATE_EXISTING)
Files.move(part, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
}
fun remove(root: File, progress: (Int) -> Unit = {}): Result {
val entries = entries(root)
var removed = 0
var restored = 0
var kept = 0
val originals = File(root, ORIGINALS)
val live = File(root, STATUS)
val userStatus = if (live.isFile) live.readText() else null
for (entry in entries.filter { it.kind == 'C' || it.kind == 'R' }) {
val file = inside(root, entry.path) ?: continue
if (entry.type == 'D') {
file.mkdirs()
continue
}
if (entry.kind == 'C' && !entry.path.startsWith("var/lib/dpkg/") && !matchesOrMissing(file, entry)) {
kept++
continue
}
val source = inside(originals, entry.path) ?: continue
val sourcePath = source.toPath()
if (!Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) continue
file.parentFile?.mkdirs()
val part = LinuxRuntimeInstaller.partFile(file).toPath()
Files.deleteIfExists(part)
if (Files.isSymbolicLink(sourcePath)) Files.createSymbolicLink(part, Files.readSymbolicLink(sourcePath))
else Files.copy(sourcePath, part, StandardCopyOption.COPY_ATTRIBUTES)
if (Files.isDirectory(file.toPath(), LinkOption.NOFOLLOW_LINKS)) FileUtils.delete(file)
Files.move(part, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
restored++
}
val added = entries.filter { it.kind == 'N' }
val directories = ArrayList<File>()
added.forEachIndexed { index, entry ->
val file = inside(root, entry.path)
when {
file == null -> kept++
entry.type == 'D' -> directories += file
matches(file, entry) -> { Files.deleteIfExists(file.toPath()); removed++ }
Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS) -> kept++
}
if (index % 2000 == 0) progress(index * 100 / added.size.coerceAtLeast(1))
}
for (dir in directories.sortedByDescending { it.path.length }) {
if (Files.isDirectory(dir.toPath(), LinkOption.NOFOLLOW_LINKS) && dir.list()?.isEmpty() == true) dir.delete()
}
val packStatus = File(root, PACK_STATUS)
if (userStatus != null && packStatus.isFile && live.isFile) {
writeStatus(live, mergeStatus(packStatus.readText(), live.readText(), userStatus))
}
FileUtils.delete(File(root, META))
return Result(removed, restored, kept)
}
private fun matchesOrMissing(file: File, entry: Entry) =
!Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS) || matches(file, entry)
private fun matches(file: File, entry: Entry): Boolean {
val path = file.toPath()
return when (entry.type) {
'L' -> Files.isSymbolicLink(path) && Files.readSymbolicLink(path).toString() == entry.value
'F' -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && file.length() == entry.size &&
Hashes.sha256(file).equals(entry.value, ignoreCase = true)
else -> false
}
}
internal fun inside(root: File, path: String): File? {
if (path.isEmpty() || path.startsWith("/")) return null
val normal = Paths.get(path).normalize()
if (normal.toString().isEmpty() || normal.startsWith("..")) return null
val parent = normal.parent
val dir = if (parent == null) root else com.droiddeck.launcher.core.ArchivePaths.inside(root, parent.toString()) ?: return null
return File(dir, normal.fileName.toString())
}
}
@@ -18,8 +18,11 @@ internal object DesktopRemoval {
private fun protected(context: Context, root: File) =
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
fun installedSize(context: Context): Long =
lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
fun installedSize(context: Context): Long {
val root = LinuxRuntime.rootDir(context)
if (DesktopPack.info(root) != null) return DesktopPack.installedSize(root)
return lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
}
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
val root = LinuxRuntime.rootDir(context)
@@ -40,8 +43,12 @@ internal object DesktopRemoval {
Log.i("DesktopRemoval", "LXQt: $result")
}
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? {
val root = LinuxRuntime.rootDir(context)
return if (DesktopPack.info(root) != null) removePack(context, root, listener) else removeLegacy(context, root, listener)
}
private fun removeLegacy(context: Context, root: File, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
val pending = File(root, PENDING)
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
val installing = DesktopCatalog.interrupted(root).firstOrNull { it.id == DesktopCatalog.DESKTOP_ID }
@@ -76,5 +83,29 @@ internal object DesktopRemoval {
e.message ?: context.getString(R.string.deskpkg_install_failed)
}
private fun removePack(context: Context, root: File, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
val pending = File(root, PENDING)
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
FileUtils.writeString(pending, DesktopPack.info(root)!!.tag)
check(pending.isFile) { "Could not reserve desktop removal" }
if (marker.exists() && !marker.delete()) error("Could not clear desktop marker")
val removing = context.getString(R.string.desktop_removing)
for ((_, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
DesktopFileRemoval.safeFile(root, path)?.takeIf { isRegularFile(it) }?.delete()
}
for (path in DESKTOP_STATE) DesktopPack.inside(root, path)?.let(FileUtils::delete)
val result = if (DesktopPack.installed(root)) DesktopPack.remove(root) { listener.onProgress(removing, it) } else null
DesktopCatalog.discardDesktopInstall(context)
if (!pending.delete()) error("Could not finish desktop removal")
Log.i("DesktopRemoval", "pack: $result")
listener.onProgress(context.getString(R.string.desktop_removed), 100)
null
} catch (e: Exception) {
Log.e("DesktopRemoval", "remove", e)
e.message ?: context.getString(R.string.deskpkg_install_failed)
}
private val DESKTOP_STATE = listOf("var/lib/PackageKit", "var/cache/swcatalog")
private fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)
}
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.runtime
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -34,6 +36,9 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
private val observer = object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) { if (running) publish() }
}
private val volumeSteps = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { if (running) publishVolume() }
}
private val requests = object : FileObserver(directory.path, CLOSE_WRITE or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (path in REQUESTS) handler.post { if (running) consume() }
@@ -50,13 +55,22 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
}
}
fun publish(force: Boolean = false) {
private fun publishVolume() {
try {
directory.mkdirs()
val level = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
val maximum = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
val muted = audio.isStreamMute(AudioManager.STREAM_MUSIC)
write("volume.state", "$level $maximum ${if (muted) 1 else 0}\n")
} catch (e: Exception) {
Log.w(TAG, "Could not publish Android volume", e)
}
}
fun publish(force: Boolean = false) {
publishVolume()
try {
directory.mkdirs()
val value = Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128)
write("backlight/droiddeck/brightness", "$value\n")
write("backlight/droiddeck/actual_brightness", "$value\n")
@@ -81,6 +95,10 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
permissionNotified = false
context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, observer)
requests.startWatching()
context.registerReceiver(volumeSteps, IntentFilter().apply {
addAction(VOLUME_CHANGED_ACTION)
addAction(STREAM_MUTE_CHANGED_ACTION)
}, null, handler)
refresh.run()
}
}
@@ -90,6 +108,7 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
running = false
handler.removeCallbacks(refresh)
requests.stopWatching()
try { context.unregisterReceiver(volumeSteps) } catch (e: IllegalArgumentException) { }
context.contentResolver.unregisterContentObserver(observer)
context.getSystemService(NotificationManager::class.java).cancel(PERMISSION_NOTIFICATION)
}
@@ -170,6 +189,9 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
companion object {
private const val TAG = "DeviceControls"
// AudioService sends these on every stream step; the Settings observer and 250 ms poll lag a held volume key.
private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION"
private const val STREAM_MUTE_CHANGED_ACTION = "android.media.STREAM_MUTE_CHANGED_ACTION"
private const val PERMISSION_CHANNEL = "brightness_permission"
private const val PERMISSION_NOTIFICATION = 0x4442
private val REQUESTS = setOf("brightness.request", "volume.request", "mute.request")
@@ -43,7 +43,7 @@ object GuestCommand {
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
@@ -113,12 +113,16 @@ public final class LinuxRuntime {
}
public static boolean isInstalled(Context context) {
return hasRootfs(context) && (patchedProot(context) || rootfsProotReady(context));
}
/** The rootfs alone: enough to read the game library, while the proot pack can still be downloading. */
public static boolean hasRootfs(Context context) {
if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false;
File root = rootDir(context);
return new File(root, "usr/bin/gamescope").isFile()
return (new File(root, "usr/bin/gamescope").isFile() || new File(root, "usr/games/gamescope").isFile())
&& (new File(root, SESSION_SCRIPT.substring(1)).isFile()
|| new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile())
&& (patchedProot(context) || rootfsProotReady(context));
|| new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile());
}
public static File vulkanIcd(Context context) {
@@ -341,9 +345,9 @@ public final class LinuxRuntime {
}
public static String cpuTempSource() {
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(thermalZones()).entrySet()) {
File temp = new File(e.getValue(), "temp");
if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) return temp.getPath();
for (File zone : cpuTempZones()) {
File temp = new File(zone, "temp");
if (temp.canRead()) return temp.getPath();
}
return null;
}
@@ -353,7 +357,7 @@ public final class LinuxRuntime {
return firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
}
private static String gpuLoadSource() {
public static String gpuLoadSource() {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
return firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load");
}
@@ -367,7 +371,7 @@ public final class LinuxRuntime {
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
{ddrTempSource(), "/sys/class/thermal/thermal_zone22/temp"},
};
for (String[] stat : stats) {
boolean fed = false;
@@ -384,6 +388,36 @@ public final class LinuxRuntime {
return path != null ? path : "none";
}
private static String ddrTempSource() {
return thermalZone("ddr");
}
// MangoHud reads the GPU and DDR temperatures from these zone numbers (bindAdrenoStats). On phones
// where they are CPU zones, their type follows the temperature put there, so they stay out of the
// CPU average.
private static java.util.Map<String, String> replacedZoneTypes() {
java.util.Map<String, String> replaced = new java.util.HashMap<>();
if (gpuTempSource() != null) {
replaced.put("thermal_zone28", "gpu-thermal");
replaced.put("thermal_zone26", "gpu-thermal");
}
if (ddrTempSource() != null) replaced.put("thermal_zone22", "ddr-thermal");
return replaced;
}
/** The zones the session's MangoHud averages for the CPU temperature (all typed cpuN-thermal there). */
public static List<File> cpuTempZones() {
java.util.Map<String, String> replaced = replacedZoneTypes();
List<File> zones = new ArrayList<>();
for (java.util.Map.Entry<String, File> zone : new java.util.TreeMap<>(thermalZones()).entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !replaced.containsKey(zone.getValue().getName())) {
zones.add(zone.getValue());
}
}
return zones;
}
private static void bindCpuTemps(List<String> cmd, File root) {
File name = new File(root, "etc/droiddeck/cpu-thermal-type");
try {
@@ -391,9 +425,18 @@ public final class LinuxRuntime {
} catch (IOException e) {
return;
}
java.util.Map<String, String> replaced = replacedZoneTypes();
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
String other = replaced.get(zone.getValue().getName());
if (other != null) {
File typeFile = new File(root, "etc/droiddeck/" + other + "-type");
try {
if (!typeFile.isFile()) Files.write(typeFile.toPath(), (other + "\n").getBytes(StandardCharsets.US_ASCII));
bind(cmd, typeFile.getPath() + ":" + new File(zone.getValue(), "type").getPath());
} catch (IOException ignored) {
}
} else if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
}
}
@@ -448,12 +491,72 @@ public final class LinuxRuntime {
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
}
public static void writeAccounts(Context context) throws IOException {
public static synchronized void writeAccounts(Context context) throws IOException {
File root = rootDir(context);
int uid = Process.myUid();
Files.write(new File(root, "etc/passwd").toPath(),
("root:x:" + uid + ":" + uid + ":root:/root:/bin/bash\n").getBytes(StandardCharsets.UTF_8));
Files.write(new File(root, "etc/group").toPath(),
("root:x:" + uid + ":\n").getBytes(StandardCharsets.UTF_8));
mergeAccounts(new File(root, "etc/passwd"), 2,
"root:x:" + uid + ":" + uid + ":root:/root:/bin/bash", "root:x:0:0:root:/root:/bin/bash", uid);
mergeAccounts(new File(root, "etc/group"), 2,
"root:x:" + uid + ":", "root:x:0:", uid);
File group = new File(root, "etc/group");
String groups = new String(Files.readAllBytes(group.toPath()), StandardCharsets.UTF_8);
writeIfChanged(group, groups, withStorageGroups(groups));
}
private static final String[][] STORAGE_GROUPS = {
{"sdcard_rw", "1015"}, {"media_rw", "1023"}, {"everybody", "9997"}};
static String withStorageGroups(String groups) {
StringBuilder out = new StringBuilder();
List<String> present = new ArrayList<>();
for (String line : groups.split("\n")) {
if (line.trim().isEmpty()) continue;
String[] fields = line.split(":", -1);
if (fields.length >= 4) {
for (String[] storage : STORAGE_GROUPS) {
if (fields[0].equals(storage[0])) present.add(storage[0]);
if (!fields[2].equals(storage[1])) continue;
present.add(storage[0]);
if (!java.util.Arrays.asList(fields[3].split(",")).contains("root")) {
fields[3] = fields[3].isEmpty() ? "root" : fields[3] + ",root";
line = String.join(":", fields);
}
}
}
out.append(line).append('\n');
}
for (String[] storage : STORAGE_GROUPS) {
if (!present.contains(storage[0])) {
out.append(storage[0]).append(":x:").append(storage[1]).append(":root\n");
}
}
return out.toString();
}
static String mergedAccounts(String existing, int idField, String own, String zero, int uid) {
StringBuilder out = new StringBuilder(own).append('\n').append(zero).append('\n');
if (existing == null) return out.toString();
String ownId = Integer.toString(uid);
for (String line : existing.split("\n")) {
if (line.trim().isEmpty()) continue;
String[] fields = line.split(":", -1);
if (fields.length <= idField) continue;
if (fields[0].equals("root") || fields[idField].equals("0") || fields[idField].equals(ownId)) continue;
out.append(line).append('\n');
}
return out.toString();
}
private static void mergeAccounts(File file, int idField, String own, String zero, int uid) throws IOException {
String existing = file.isFile() ? new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8) : null;
writeIfChanged(file, existing, mergedAccounts(existing, idField, own, zero, uid));
}
private static void writeIfChanged(File file, String existing, String merged) throws IOException {
if (merged.equals(existing)) return;
File staged = File.createTempFile(file.getName(), ".staged", file.getParentFile());
Files.write(staged.toPath(), merged.getBytes(StandardCharsets.UTF_8));
Files.move(staged.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -35,7 +35,7 @@ public final class LinuxRuntimeInstaller {
private static final String TAG = "LinuxRuntimeInstaller";
public static final String CATALOG_URL =
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs-ubuntu.json";
private static final String VERSION_FILE = ".version";
@@ -88,8 +88,20 @@ public final class LinuxRuntimeInstaller {
return v == null ? null : v.trim();
}
private static String catalogUrl() {
if (!com.droiddeck.launcher.BuildConfig.DEBUG) return CATALOG_URL;
try {
Object value = Class.forName("android.os.SystemProperties").getMethod("get", String.class)
.invoke(null, "debug.droiddeck.linuxfs");
if (value instanceof String && ((String) value).startsWith("http")) return (String) value;
} catch (ReflectiveOperationException e) {
Log.w(TAG, "catalog override: " + e);
}
return CATALOG_URL;
}
public static Release fetchRelease() {
String body = Downloader.downloadString(CATALOG_URL);
String body = Downloader.downloadString(catalogUrl());
if (body == null || body.isEmpty()) return null;
try {
JSONObject json = new JSONObject(body);
@@ -292,7 +304,7 @@ public final class LinuxRuntimeInstaller {
recoverInterruptedSwap(root, staging, old);
if (!discard(staging, null) || !staging.mkdirs()) return false;
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
if (!extract(context, archive, staging, root, listener)) {
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -346,7 +358,11 @@ public final class LinuxRuntimeInstaller {
return false;
}
new File(root, CARRY_MARKER).delete();
try { RuntimeFileTree.dropStaleState(root); }
catch (IOException e) { Log.w(TAG, "could not clear caches left by the previous runtime", e); }
discard(old, null);
try { com.droiddeck.launcher.session.SessionFiles.INSTANCE.stage(context, root, false); }
catch (Exception e) { Log.w(TAG, "could not stage the session files into the new runtime", e); }
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
Log.e(TAG, "install", e);
@@ -513,6 +529,10 @@ 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) {
return extract(context, archive, destination, destination, listener);
}
static boolean extract(Context context, File archive, File destination, File linkRoot, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
@@ -537,7 +557,7 @@ public final class LinuxRuntimeInstaller {
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
File target = hardLinkTarget(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
@@ -548,11 +568,11 @@ public final class LinuxRuntimeInstaller {
try {
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
if (!l2sLink(target, part, destination, linkRoot) && target.isFile()) {
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
if (Files.exists(part.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) place(part, file);
} else if (entry.isFile()) {
links.remove(file);
File part = partFile(file);
@@ -589,6 +609,67 @@ public final class LinuxRuntimeInstaller {
}
}
static File hardLinkTarget(File destination, String name) throws IOException {
java.nio.file.Path path = java.nio.file.Paths.get(name).normalize();
java.nio.file.Path last = path.getFileName();
if (last == null || last.toString().isEmpty() || last.toString().equals("..")) return null;
java.nio.file.Path parent = path.getParent();
File dir = parent == null ? destination : ArchivePaths.inside(destination, parent.toString());
return dir == null ? null : new File(dir, last.toString());
}
private static final String L2S = ".l2s.";
static boolean l2sLink(File target, File link) {
return l2sLink(target, link, null, null);
}
static boolean l2sLink(File target, File link, File tree, File linkTree) {
java.nio.file.Path here = tree == null ? null : tree.toPath().toAbsolutePath().normalize();
java.nio.file.Path there = linkTree == null ? here : linkTree.toPath().toAbsolutePath().normalize();
java.util.function.UnaryOperator<java.nio.file.Path> shown = path -> {
java.nio.file.Path p = path.toAbsolutePath().normalize();
return here == null || !p.startsWith(here) ? p : there.resolve(here.relativize(p));
};
java.util.function.UnaryOperator<java.nio.file.Path> actual = path -> {
java.nio.file.Path p = path.toAbsolutePath().normalize();
return there == null || !p.startsWith(there) ? p : here.resolve(there.relativize(p));
};
try {
java.nio.file.Path original = target.toPath().toAbsolutePath().normalize();
java.nio.file.Path intermediate;
if (Files.isSymbolicLink(original)) {
intermediate = actual.apply(Files.readSymbolicLink(original));
if (!intermediate.getFileName().toString().startsWith(L2S) || !Files.isSymbolicLink(intermediate)) return false;
java.nio.file.Path fin = actual.apply(Files.readSymbolicLink(intermediate));
String name = fin.getFileName().toString();
int count = Integer.parseInt(name.substring(name.length() - 4)) + 1;
java.nio.file.Path next = fin.resolveSibling(name.substring(0, name.length() - 4) + String.format(java.util.Locale.ROOT, "%04d", count));
Files.move(fin, next, StandardCopyOption.ATOMIC_MOVE);
Files.delete(intermediate);
Files.createSymbolicLink(intermediate, shown.apply(next));
} else {
if (!Files.isRegularFile(original, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false;
java.nio.file.Path dir = original.getParent();
String base = L2S + original.getFileName();
int suffix = 1;
do {
intermediate = dir.resolve(base + String.format(java.util.Locale.ROOT, "%04d", suffix++));
} while (Files.exists(intermediate, java.nio.file.LinkOption.NOFOLLOW_LINKS) && suffix < 1000);
java.nio.file.Path fin = dir.resolve(intermediate.getFileName() + ".0002");
Files.move(original, fin, StandardCopyOption.ATOMIC_MOVE);
Files.createSymbolicLink(intermediate, shown.apply(fin));
Files.createSymbolicLink(original, shown.apply(intermediate));
}
Files.deleteIfExists(link.toPath());
Files.createSymbolicLink(link.toPath(), shown.apply(intermediate));
return true;
} catch (IOException | RuntimeException e) {
Log.w(TAG, "l2s link " + link + " -> " + target + ": " + e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Build
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
@@ -37,9 +38,10 @@ object ProotFastPath {
fun hostEnv(key: String): Pair<String, String> = "PROOT_FASTPATH" to key
fun guestEnv(root: File, binds: List<String>, key: String): List<String> = listOf(
fun guestEnv(root: File, binds: List<String>, key: String, sdk: Int = Build.VERSION.SDK_INT): List<String> = listOf(
"PROOT_FP_ROOT=" + root.path,
"PROOT_FP_BINDS=" + binds.joinToString("|"),
"PROOT_FP_KEY=$key",
"PROOT_FP_ANDROID_API=$sdk",
)
}
@@ -0,0 +1,201 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.net.LocalServerSocket
import android.net.LocalSocket
import android.os.ParcelFileDescriptor
import android.os.Process
import android.util.Log
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicInteger
object RootSpawner {
private const val TAG = "RootSpawner"
fun socket(context: Context): String = "${context.packageName}.root"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
@Synchronized
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val name = socket(app)
val socket = try {
LocalServerSocket(name)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $name", e)
return
}
server = socket
Thread({
while (true) {
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
Thread({ serve(app, client) }, "root-${seq.get() + 1}").apply { isDaemon = true; start() }
}
}, "root-server").apply { isDaemon = true; start() }
}
private fun serve(context: Context, client: LocalSocket) {
val id = seq.incrementAndGet()
val received = ArrayList<FileDescriptor>()
client.use {
val out = DataOutputStream(client.outputStream)
try {
if (client.peerCredentials.uid != Process.myUid()) {
Log.w(TAG, "refused root for uid ${client.peerCredentials.uid}")
return
}
val input = DataInputStream(client.inputStream)
val kind = input.readUnsignedByte()
client.ancillaryFileDescriptors?.let { received.addAll(it) }
val body = ByteArray(input.readInt()).also { input.readFully(it) }
client.ancillaryFileDescriptors?.let { received.addAll(it) }
if (kind != 'R'.code) return
run(context, JSONObject(String(body, Charsets.UTF_8)), received, client, input, out, id)
} catch (e: Exception) {
Log.e(TAG, "root $id", e)
runCatching {
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
frame(out, 'X', "1".toByteArray())
}
} finally {
received.forEach { fd -> runCatching { android.system.Os.close(fd) } }
}
}
}
private class Stdio(val redirect: ProcessBuilder.Redirect?, val fd: FileDescriptor?, val dup: ParcelFileDescriptor?)
private fun stdio(fd: FileDescriptor?, write: Boolean): Stdio {
if (fd == null) return Stdio(null, null, null)
val dup = try { ParcelFileDescriptor.dup(fd) } catch (e: IOException) { return Stdio(null, fd, null) }
val path = File("/proc/self/fd/${dup.fd}")
val opens = try {
if (write) FileOutputStream(path, true).close() else FileInputStream(path).close()
true
} catch (e: IOException) { false }
if (!opens) return Stdio(null, fd, dup)
val redirect = if (write) ProcessBuilder.Redirect.appendTo(path) else ProcessBuilder.Redirect.from(path)
return Stdio(redirect, fd, dup)
}
private fun pump(from: InputStream, to: OutputStream, name: String) =
Thread({
val buffer = ByteArray(16384)
try {
while (true) {
val n = from.read(buffer)
if (n < 0) break
to.write(buffer, 0, n)
to.flush()
}
} catch (e: IOException) {
} finally {
runCatching { to.close() }
}
}, name).apply { isDaemon = true; start() }
private fun run(context: Context, request: JSONObject, fds: List<FileDescriptor>, client: LocalSocket,
input: DataInputStream, out: DataOutputStream, id: Int) {
val root = LinuxRuntime.rootDir(context)
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
if (argv.isEmpty()) throw IOException("no command")
val cwd = request.optString("cwd", "/root").ifEmpty { "/root" }
val cmd = LinuxRuntime.prootPrefix(context, root, cwd, true)
cmd.add(1, "--link2symlink")
LinuxRuntime.lastBinds(context).filterNot { it.startsWith("/proc/self/fd/") }.forEach { cmd.add("-b"); cmd.add(it) }
cmd.add("/usr/bin/env")
cmd.add("-i")
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> cmd.add("$k=${e.getString(k)}") } }
cmd.addAll(argv)
val builder = ProcessBuilder(cmd).directory(root)
builder.environment().apply {
clear()
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val streams = listOf(stdio(fds.getOrNull(0), false), stdio(fds.getOrNull(1), true), stdio(fds.getOrNull(2), true))
try {
builder.redirectInput(streams[0].redirect ?: ProcessBuilder.Redirect.PIPE)
builder.redirectOutput(streams[1].redirect ?: ProcessBuilder.Redirect.PIPE)
builder.redirectError(streams[2].redirect ?: ProcessBuilder.Redirect.PIPE)
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
Log.i(TAG, "root $id pid $pid: ${argv.joinToString(" ")}")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
val pumps = ArrayList<Thread>()
if (streams[0].redirect == null) {
val fd = streams[0].fd
if (fd != null) pumps.add(pump(FileInputStream(fd), process.outputStream, "root-$id-in").also { it.isDaemon = true })
else runCatching { process.outputStream.close() }
}
if (streams[1].redirect == null) pumps.add(pump(process.inputStream,
streams[1].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-out"))
if (streams[2].redirect == null) pumps.add(pump(process.errorStream,
streams[2].fd?.let { FileOutputStream(it) } ?: OutputStream.nullOutputStream(), "root-$id-err"))
Thread({
try {
while (true) {
val kind = input.readUnsignedByte()
val body = ByteArray(input.readInt()).also { input.readFully(it) }
if (kind == 'S'.code) String(body).trim().toIntOrNull()?.let { signal(pid, it) }
}
} catch (e: IOException) {
}
if (process.isAlive) {
Log.i(TAG, "root $id: its caller is gone; stopping it")
signal(pid, android.system.OsConstants.SIGHUP)
android.os.Process.sendSignal(pid, android.system.OsConstants.SIGQUIT)
}
}, "root-$id-watch").apply { isDaemon = true; start() }
val status = process.waitFor()
pumps.filter { it.name.endsWith("-out") || it.name.endsWith("-err") }.forEach { it.join(2000) }
OrphanReaper.release(pid)
Log.i(TAG, "root $id exited $status")
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
} finally {
streams.forEach { s -> runCatching { s.dup?.close() } }
}
}
private fun signal(top: Int, signal: Int) {
val parents = HashMap<Int, Int>()
File("/proc").listFiles()?.forEach { dir ->
val pid = dir.name.toIntOrNull() ?: return@forEach
val stat = try { File(dir, "stat").readText() } catch (e: IOException) { return@forEach }
val fields = stat.substringAfterLast(')').trim().split(' ')
fields.getOrNull(1)?.toIntOrNull()?.let { parents[pid] = it }
}
val tree = HashSet<Int>()
var grew = true
while (grew) {
grew = false
for ((pid, parent) in parents) {
if (pid !in tree && (parent == top || parent in tree)) { tree.add(pid); grew = true }
}
}
tree.forEach { runCatching { android.os.Process.sendSignal(it, signal) } }
}
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
out.writeByte(kind.code)
out.writeInt(payload.size)
out.write(payload)
out.flush()
}
}
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.BuildConfig
object RuntimeAutoUpdate {
private const val PREFS = "runtime-auto-update"
const val MAX_ATTEMPTS = 3
const val RETRY_MS = 10 * 60_000L
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean =
installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
fun key(appVersion: Int, release: String) = "$appVersion:$release"
fun claim(context: Context, installed: String?, release: String?, now: Long = System.currentTimeMillis()): Boolean {
if (release.isNullOrEmpty()) return false
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val key = key(BuildConfig.VERSION_CODE, release)
synchronized(this) {
val attempts = prefs.getInt(key, 0)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
val edit = prefs.edit()
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) }
edit.putInt(key, attempts + 1).putLong("$key@at", now).apply()
return true
}
}
}
@@ -80,7 +80,7 @@ public final class RuntimeFileTree {
* be carried.
*/
static java.util.List<String> userPaths(File tree) throws IOException {
java.util.List<String> paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps"));
java.util.List<String> paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps", "opt/droiddeck/wincomponents"));
for (String n : children(new File(tree, "usr/local/share/applications"))) {
if (n.endsWith(".desktop") && (n.startsWith("droiddeck-app-") || n.startsWith("droiddeck-appimage-")))
paths.add("usr/local/share/applications/" + n);
@@ -109,10 +109,39 @@ public final class RuntimeFileTree {
static java.util.List<String> existingUserPaths(File from, File to) throws IOException {
java.util.List<String> paths = new java.util.ArrayList<>();
for (String rel : userPaths(from, to)) if (exists(safePath(from, rel).toPath())) paths.add(rel);
for (String rel : userPaths(from, to)) {
if (!exists(safePath(from, rel).toPath())) continue;
if (rel.startsWith("usr/share/applications/") && exists(safePath(to, rel).toPath())) continue;
paths.add(rel);
}
return paths;
}
private static final String[] STALE_HOME_STATE = {
".local/share/droiddeck/pacman", ".cache/droiddeck/pacman-restore.log",
".cache/appstream", ".cache/discover", ".cache/fontconfig", ".cache/kwin", ".cache/ksplash",
".cache/ksvg-elements", ".cache/plasmashell", ".cache/kwinsession.kcache", ".cache/icon-cache.kcache",
};
private static final String[] STALE_HOME_CACHE_PREFIXES = {
"mesa_shader_cache", "ksycoca", "plasma_theme_", "qtshadercache", "event-sound-cache.tdb.",
};
static void dropStaleState(File runtimeRoot) throws IOException {
File home = new File(runtimeRoot, "root");
java.util.List<String> stale = new java.util.ArrayList<>(java.util.Arrays.asList(STALE_HOME_STATE));
for (String n : children(new File(home, ".cache"))) {
for (String prefix : STALE_HOME_CACHE_PREFIXES) {
if (n.startsWith(prefix)) { stale.add(".cache/" + n); break; }
}
}
IOException failed = null;
for (String rel : stale) {
try { delete(safePath(home, rel), null); }
catch (IOException e) { if (failed == null) failed = e; else failed.addSuppressed(e); }
}
if (failed != null) throw failed;
}
private static java.util.List<String> children(File dir) throws IOException {
java.util.List<String> names = new java.util.ArrayList<>();
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir.toPath())) {
@@ -11,14 +11,15 @@ object RuntimeHealth {
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)
fun brokenLibraryLinks(root: File): List<String> =
listOf("usr/lib", "usr/lib/aarch64-linux-gnu").flatMap { dir ->
val lib = File(root, dir)
val names = lib.list() ?: return@flatMap emptyList()
names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}.map { if (dir == "usr/lib") it else "aarch64-linux-gnu/$it" }
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
@@ -13,6 +13,7 @@ object GameEnvironmentStore {
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
const val DXVK_CONFIG = "dxvkConfig"
const val DXVK_CONFIG_GAMES = "dxvkConfigGames"
const val GAME_DEBUG_LOGS = "gameDebugLogs"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
@@ -87,7 +88,11 @@ object GameEnvironmentStore {
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
fun publish(
context: Context,
config: GameEnvironment.Config = read(context),
gameDebugLogsOverride: Boolean? = null,
) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
val json = encode(resolved)
val dxvk = TextureFiltering.dxvkOptions(
@@ -110,6 +115,11 @@ object GameEnvironmentStore {
)
}
if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk)
json.put(
GAME_DEBUG_LOGS,
gameDebugLogsOverride ?: (SessionPrefs.logsEnabled(context) &&
SessionPrefs.gameDebugLogsEnabled(context) && SessionPaths.currentSessionLogsRetained(context)),
)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
}
@@ -21,7 +21,7 @@ class GpuMemComponent(private val file: File, private val root: File) : SessionP
thread = Thread({
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (running && OverlayShown.check(root)) sample()?.let { write(it) }
if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.FusionHudHost.showing)) sample()?.let { write(it) }
}
}, "gpu-mem").apply { isDaemon = true; start() }
}
@@ -51,7 +51,8 @@ object NoSteamLaunch {
fun addedGame(context: Context, game: Library.SteamGame): AddedGames.Game? {
val id = game.appId.toLong() and 0xFFFFFFFFL
return AddedGames.scan(context).firstOrNull { settingsId(it) == id }
val one = game.gameFiles?.let { AddedGames.single(context, it.path) }
return one?.takeIf { settingsId(it) == id } ?: AddedGames.scan(context).firstOrNull { settingsId(it) == id }
}
fun nameFor(program: String?, args: List<String>): String? = when (program) {
@@ -1,178 +0,0 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
class PerfHud(context: Context) {
var text by mutableStateOf("")
private set
var graph by mutableStateOf(FloatArray(0))
private set
private val context = context.applicationContext
private val frames = AtomicInteger()
private var lastTick = 0L
private var lastFps = 0f
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val samples = FloatArray(GRAPH)
private var graphBase = 0L
private var graphSeen = 0L
@Volatile private var showFps = true
@Volatile private var showBattery = true
@Volatile private var showGraph = true
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context)
&& !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
private val anyPart: Boolean
get() = showFps || showBattery || showGraph
var onPresentingWindowChanged: (() -> Unit)? = null
private var lastWindow: String? = null
private val listener = object : WaylandCompositor.GameListener {
override fun onGameSurface(window: String?, gpuName: String?) {
if (window != null && window != lastWindow) {
lastWindow = window
handler.post { onPresentingWindowChanged?.invoke() }
}
}
override fun onGameFrame() {
frames.incrementAndGet()
}
}
private val tick = object : Runnable {
override fun run() {
if (!running) return
val now = SystemClock.elapsedRealtime()
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
lastTick = now
lastFps = frames.getAndSet(0) / dt
text = readout(lastFps)
if (showGraph) publishGraph() else clearGraph()
handler.postDelayed(this, TICK_MS)
}
}
fun refresh() {
readOptions()
if (!enabled || !anyPart) {
stop()
return
}
if (!running) start() else {
if (!showGraph) clearGraph() else publishGraph()
text = readout(lastFps)
}
}
fun start() {
readOptions()
if (!enabled || !anyPart || running) return
running = true
lastTick = SystemClock.elapsedRealtime()
frames.set(0)
graphBase = shownTotal()
graphSeen = graphBase
WaylandCompositor.setGameListener(listener)
handler.postDelayed(tick, TICK_MS)
}
fun stop() {
running = false
handler.removeCallbacks(tick)
WaylandCompositor.clearGameListener(listener)
text = ""
graph = FloatArray(0)
}
private fun readOptions() {
showFps = SessionPrefs.hudShowFps(context)
showBattery = SessionPrefs.hudShowBattery(context)
showGraph = SessionPrefs.hudShowGraph(context)
}
private fun readout(fpsValue: Float): String {
val parts = ArrayList<String>(2)
if (showFps) parts.add(line(fpsValue))
if (showBattery) batteryCelsius(readBatteryDeci())?.let { parts.add(it) }
return parts.joinToString("\n")
}
private fun readBatteryDeci(): Int {
val sticky = runCatching {
context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}.getOrNull() ?: return 0
return sticky.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0)
}
private fun shownTotal(): Long = try { WaylandCompositor.nativeHudFrameTimes(samples) } catch (t: Throwable) { 0L }
private fun clearGraph() {
graphBase = shownTotal()
graphSeen = graphBase
if (graph.isNotEmpty()) graph = FloatArray(0)
}
private fun publishGraph() {
val total = shownTotal()
if (total == graphSeen && graph.isNotEmpty()) return
graphSeen = total
graph = recent(samples, total, graphBase)
}
private fun line(base: Float): String {
val mode = FrameGen.mode(context)
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
val name = if (lsfg) "LSFG" else "DD-FG"
val label = FrameGen.label(context, mode)
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 }
return when {
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
stats != null && stats[3] > 1f -> String.format(Locale.US, "%s %.0f → %.0f fps", label, base, stats[3])
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
}
}
private fun fps(value: Float) = String.format(Locale.US, "%.0f fps", value)
companion object {
private const val TICK_MS = 1_000L
private const val GRAPH = 128
internal const val GRAPH_MAX_MS = 50f
internal fun recent(samples: FloatArray, total: Long, base: Long): FloatArray {
val filled = minOf(total, samples.size.toLong()).toInt()
val shown = minOf(filled.toLong(), (total - base).coerceAtLeast(0L)).toInt()
if (shown < 2) return FloatArray(0)
return FloatArray(shown) { samples[filled - shown + it].coerceIn(0f, GRAPH_MAX_MS) }
}
internal fun batteryCelsius(deci: Int): String? =
if (deci <= 0) null else String.format(Locale.US, "%.1f°C", deci / 10f)
}
}
@@ -7,6 +7,9 @@ import com.droiddeck.launcher.core.SessionLogCapture
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.io.Writer
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -16,6 +19,9 @@ object SessionArtifacts {
const val COMPLETE_MARKER = ".complete"
private const val RECOVERY_RUNTIME_CAPTURED_MARKER = ".recovery-runtime-captured"
private const val RECOVERY_RUNTIME_EXPIRED_MARKER = ".recovery-runtime-expired"
private val SCRUBBED_MARKER = ".scrubbed-r${LogRedactor.RULES_VERSION}"
private val OLD_SCRUBBED_MARKER = Regex("""\.scrubbed-(r?\d+)""")
@@ -72,9 +78,9 @@ object SessionArtifacts {
copySteamLogs(context, dir, record, steamArch)
VacDiagnostics.collect(context, dir)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
markScrubbed(scrubTree(record))
val scrubbed = markScrubbed(scrubTree(record))
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
if (scrubbed) File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
Log.w(TAG, "collecting session artifacts", e)
}
@@ -88,15 +94,10 @@ object SessionArtifacts {
}?.toList() ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
val workers = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4)
val pool = java.util.concurrent.Executors.newFixedThreadPool(workers) { r ->
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "scrub-logs")
}
val done = try {
pool.invokeAll(dirs.map { dir -> java.util.concurrent.Callable { scrubAndMark(dir) } })
.count { f -> try { f.get() } catch (e: Exception) { false } }
} finally {
pool.shutdown()
var done = 0
for (dir in dirs) {
if (SessionState.running) break
if (scrubAndMark(dir, stopWhenSessionStarts = true)) done++
}
val failed = dirs.size - done
Log.i(TAG, "logs: scrubbed $done folders under r${LogRedactor.RULES_VERSION}" +
@@ -114,35 +115,121 @@ object SessionArtifacts {
private class Record(val dir: File) {
val files = HashMap<File, Scrubbed>()
var failed = false
var deferred = false
}
private fun scrubFolder(dir: File, record: Record) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
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
private sealed class ScrubResult {
class Done(val scrubbed: Scrubbed) : ScrubResult()
object Failed : ScrubResult()
object Deferred : ScrubResult()
}
private fun scrubFolder(
dir: File,
record: Record,
shouldStop: () -> Boolean,
beforeReplace: (File) -> Unit,
listFiles: (File) -> Array<File>?,
): Boolean {
val files = listFiles(dir)?.filter { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }
if (files == null) {
record.failed = true
return true
}
for (f in files) {
if (shouldStop()) return false
if (LogRedactor.isBinary(f)) continue
when (val result = scrubFile(dir, f, shouldStop, beforeReplace)) {
is ScrubResult.Done -> record.files[f] = result.scrubbed
ScrubResult.Failed -> record.failed = true
ScrubResult.Deferred -> {
record.failed = true
record.deferred = true
return false
}
}
}
return true
}
private fun scrubFile(dir: File, f: File): Scrubbed? {
private fun scrubFile(
dir: File,
f: File,
shouldStop: () -> Boolean,
beforeReplace: (File) -> Unit,
): ScrubResult {
val tmp = File(dir, ".${f.name}.scrub")
return try {
val before = Scrubbed(f.length(), f.lastModified())
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
val scrubbed = tmp.readBytes()
if (scrubbed.contentEquals(f.readBytes())) {
val scrubbed = tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w, shouldStop) }
if (!scrubbed) {
tmp.delete()
before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
} else {
if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() }
Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length }
return ScrubResult.Deferred
}
val scrubbedLength = tmp.length()
when (sameContents(tmp, f, shouldStop)) {
null -> {
tmp.delete()
ScrubResult.Deferred
}
true -> {
tmp.delete()
before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
?.let { ScrubResult.Done(it) } ?: ScrubResult.Failed
}
false -> {
if (shouldStop()) {
tmp.delete()
return ScrubResult.Deferred
}
beforeReplace(f)
if (f.length() != before.length || f.lastModified() != before.modified) {
tmp.delete()
return ScrubResult.Failed
}
if (!tmp.renameTo(f)) {
tmp.delete()
return ScrubResult.Failed
}
Scrubbed(scrubbedLength, f.lastModified()).takeIf { f.length() == it.length }
?.let { ScrubResult.Done(it) } ?: ScrubResult.Failed
}
}
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
tmp.delete()
null
ScrubResult.Failed
}
}
internal fun sameContents(a: File, b: File, shouldStop: () -> Boolean = { false }): Boolean? {
if (shouldStop()) return null
if (a.length() != b.length()) return false
fun readChunk(input: java.io.InputStream, buffer: ByteArray): Int? {
var count = 0
while (count < buffer.size) {
if (shouldStop()) return null
val read = input.read(buffer, count, buffer.size - count)
if (shouldStop()) return null
if (read < 0) break
if (read == 0) continue
count += read
}
return count
}
return a.inputStream().buffered().use { left ->
b.inputStream().buffered().use { right ->
val leftBuffer = ByteArray(64 * 1024)
val rightBuffer = ByteArray(64 * 1024)
while (true) {
val leftCount = readChunk(left, leftBuffer) ?: return null
val rightCount = readChunk(right, rightBuffer) ?: return null
if (leftCount != rightCount) return false
if (leftCount == 0) break
if (shouldStop()) return null
for (i in 0 until leftCount) if (leftBuffer[i] != rightBuffer[i]) return false
}
true
}
}
}
@@ -183,10 +270,44 @@ object SessionArtifacts {
}.toSet()
}
internal fun scrubAndMark(dir: File): Boolean = markScrubbed(scrubTree(Record(dir)))
internal fun scrubAndMark(
dir: File,
stopWhenSessionStarts: Boolean = false,
beforeReplace: (File) -> Unit = {},
listFiles: (File) -> Array<File>? = { it.listFiles() },
sessionStarted: () -> Boolean = { SessionState.running },
): Boolean {
val shouldStop = { stopWhenSessionStarts && sessionStarted() }
val record = scrubTree(
Record(dir),
listFiles = listFiles,
beforeReplace = beforeReplace,
shouldStop = shouldStop,
)
if (shouldStop()) {
record.failed = true
record.deferred = true
}
return markScrubbed(record)
}
private fun scrubTree(record: Record): Record {
record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) }
private fun scrubTree(
record: Record,
listFiles: (File) -> Array<File>? = { it.listFiles() },
beforeReplace: (File) -> Unit = {},
shouldStop: () -> Boolean = { false },
): Record {
for (dir in record.dir.walkTopDown().filter { it.isDirectory }) {
if (shouldStop() || !scrubFolder(dir, record, shouldStop, beforeReplace, listFiles)) {
record.failed = true
record.deferred = true
break
}
}
if (shouldStop()) {
record.failed = true
record.deferred = true
}
return record
}
@@ -205,6 +326,7 @@ object SessionArtifacts {
private const val HEAD = 4096L
private class Mark(val size: Long, val head: String)
private class CopyResult(val files: List<File>, val failed: Boolean, val deferred: Boolean)
fun markSteamLogs(dir: File, logs: File) {
val lines = logs.listFiles { f -> f.isFile }?.mapNotNull { f ->
@@ -240,23 +362,54 @@ object SessionArtifacts {
.firstOrNull { m -> heads.getOrPut(minOf(m.size, HEAD)) { head(src, m.size) } == m.head }?.size ?: 0L
}
private fun copySteamLogs(context: Context, dir: File, record: Record, steamArch: String? = null) {
private fun copySteamLogs(
context: Context,
dir: File,
record: Record,
steamArch: String? = null,
stopWhenSessionStarts: Boolean = false,
) {
val logs = File(steamArch?.let { SessionPrefs.steamRoot(context, it) } ?: SessionPrefs.activeSteamRoot(context), "logs")
if (!logs.isDirectory) return
val out = File(dir, "steam").apply { mkdirs() }
copySessionLogs(logs, dir, out).forEach { dst -> record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) }
val result = copySessionLogsResult(logs, dir, out) { stopWhenSessionStarts && SessionState.running }
result.files.forEach { dst -> record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) }
record.failed = record.failed || result.failed
record.deferred = record.deferred || result.deferred
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
}
internal fun copySessionLogs(logs: File, dir: File, out: File): List<File> {
internal fun copySessionLogs(
logs: File,
dir: File,
out: File,
openWriter: (File) -> Writer = { it.bufferedWriter() },
): List<File> = copySessionLogsResult(logs, dir, out, openWriter) { false }.files
private fun copySessionLogsResult(
logs: File,
dir: File,
out: File,
openWriter: (File) -> Writer = { it.bufferedWriter() },
shouldStop: () -> Boolean,
): CopyResult {
val marks = steamLogMarks(dir)
val copied = ArrayList<File>()
logs.listFiles { f -> f.isFile }?.forEach { src ->
var failed = false
var deferred = false
val sources = logs.listFiles { f -> f.isFile } ?: return CopyResult(copied, true, false)
for (src in sources) {
if (shouldStop()) {
failed = true
deferred = true
break
}
val size = src.length()
val from = marks?.let { startOf(src, size, it) } ?: 0L
if (marks != null && from == size || size - from >= MAX_STEAM_LOG) return@forEach
if (marks != null && from == size || size - from >= MAX_STEAM_LOG) continue
val dst = File(out, src.name)
val staging = File(out, ".${src.name}.copy")
try {
val dst = File(out, src.name)
src.inputStream().buffered().use { input ->
var left = if (from > 0) from - 1 else 0L
while (left > 0) {
@@ -270,49 +423,183 @@ object SessionArtifacts {
if (c < 0 || c == '\n'.code) break
}
}
dst.bufferedWriter().use { w ->
input.bufferedReader().forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
val complete = openWriter(staging).use { w -> scrubSteamLog(input, w, shouldStop) }
if (!complete) {
staging.delete()
failed = true
deferred = true
}
}
copied.add(dst)
if (deferred) break
Files.move(
staging.toPath(),
dst.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
copied += dst
} catch (e: Exception) {
staging.delete()
Log.w(TAG, "could not scrub ${src.name}", e)
failed = true
}
}
return copied
return CopyResult(copied, failed, deferred)
}
private fun scrubSteamLog(
input: java.io.InputStream,
out: java.io.Writer,
shouldStop: () -> Boolean,
): Boolean {
input.reader().use { reader ->
val chunk = CharArray(8192)
val line = StringBuilder(4096)
var oversized = false
var pendingContent = false
var pendingCarriageReturn = false
fun emit() {
out.write(if (oversized) LogRedactor.OVERSIZED_LINE else LogRedactor.redact(line.toString()))
out.write("\n")
line.setLength(0)
oversized = false
pendingContent = false
}
while (true) {
if (shouldStop()) return false
val count = reader.read(chunk)
if (count < 0) break
if (shouldStop()) return false
for (i in 0 until count) {
if (shouldStop()) return false
val char = chunk[i]
if (pendingCarriageReturn) {
emit()
pendingCarriageReturn = false
if (char == '\n') continue
}
when (char) {
'\r' -> pendingCarriageReturn = true
'\n' -> emit()
else -> {
pendingContent = true
if (!oversized) {
if (line.length < LogRedactor.MAX_LINE_LENGTH) line.append(char)
else {
line.setLength(0)
oversized = true
}
}
}
}
}
}
if (pendingCarriageReturn || pendingContent) {
if (shouldStop()) return false
emit()
}
}
return true
}
@Synchronized
fun finishAbandoned(context: Context) {
fun finishAbandoned(context: Context, beforeGuestStart: Boolean = false) {
val stopWhenSessionStarts = !beforeGuestStart
val parent = LinuxRuntime.logDir(context)
val abandoned = parent.listFiles { f ->
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() && !inFlight(f)
}?.sortedWith(SessionPaths.chronological) ?: return
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
val latestPreviousSession = SessionPaths.sessionFolders(context).lastOrNull { it != current }
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
abandoned.forEachIndexed { i, dir ->
if (dir == current) return@forEachIndexed
val newest = i == abandoned.lastIndex
for (dir in abandoned) {
if (stopWhenSessionStarts && SessionState.running) break
if (dir == current) continue
if (beforeGuestStart && dir != latestPreviousSession) continue
val newest = dir == latestPreviousSession
try {
File(dir, "ended-without-teardown.txt").writeText(
"This session's process ended without running its own teardown - killed by\n" +
"Android, a native crash, or the device going down - so the files below were\n" +
"gathered when the app next started, at ${now()}.\n" +
"The logs written during the session (session.log, app.log, wayland.log,\n" +
"audio.log, device.txt) were on disk already and are as they were left.\n" +
(if (newest) "" else "Steam's logs are not included: a later session has overwritten them.\n") +
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
val recoveryNote = File(dir, "ended-without-teardown.txt")
val runtimeCaptured = File(dir, RECOVERY_RUNTIME_CAPTURED_MARKER)
val runtimeExpired = File(dir, RECOVERY_RUNTIME_EXPIRED_MARKER)
val recoveryPreviouslyAttempted = recoveryNote.exists()
val recoveryAttempted = recoveryPreviouslyAttempted &&
(runtimeCaptured.exists() || runtimeExpired.exists() || !beforeGuestStart)
if (!recoveryAttempted) {
recoveryNote.writeText(
"This session's process ended without running its own teardown - killed by\n" +
"Android, a native crash, or the device going down - so the files below were\n" +
"gathered when the app next started, at ${now()}.\n" +
"The logs written during the session (session.log, app.log, wayland.log,\n" +
"audio.log, device.txt) were on disk already and are as they were left.\n" +
(if (newest) "" else "Steam's logs are not included: a later session has overwritten them.\n") +
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
}
if (recoveryPreviouslyAttempted && !runtimeCaptured.exists() &&
(runtimeExpired.exists() || !beforeGuestStart) &&
!recoveryNote.readText().contains("runtime capture did not finish")) {
recoveryNote.appendText(
"Steam's logs and VAC evidence are not included: runtime capture did not finish " +
"before its evidence expired.\n"
)
}
if (recoveryPreviouslyAttempted && !runtimeCaptured.exists() && !beforeGuestStart) {
runtimeExpired.writeText("runtime evidence no longer belongs to this session, at ${now()}\n")
}
val record = Record(dir)
if (newest) {
copySteamLogs(context, dir, record, recordedClient(dir))
VacDiagnostics.collect(context, dir)
if (newest && !recoveryAttempted) {
copySteamLogs(
context,
dir,
record,
steamArch = recordedClient(dir),
stopWhenSessionStarts = stopWhenSessionStarts,
)
if (!record.deferred && (!stopWhenSessionStarts || !SessionState.running)) {
if (!record.failed && VacDiagnostics.collect(context, dir)) {
try {
runtimeCaptured.writeText("collected at ${now()}\n")
} catch (e: Exception) {
record.failed = true
if (beforeGuestStart) {
runtimeExpired.writeText("runtime capture marker failed, at ${now()}\n")
}
Log.w(TAG, "could not mark runtime capture for $dir", e)
}
} else {
record.failed = true
if (beforeGuestStart) {
runtimeExpired.writeText("runtime capture failed, at ${now()}\n")
}
}
}
}
if (record.deferred || stopWhenSessionStarts && SessionState.running) {
record.failed = true
record.deferred = true
markScrubbed(record)
break
}
val crashLog = File(dir, "crash.log")
if (!crashLog.exists()) {
SessionLogCapture.dumpCrashBuffer(crashLog)
}
if (stopWhenSessionStarts && SessionState.running) {
record.failed = true
record.deferred = true
markScrubbed(record)
break
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
markScrubbed(scrubTree(record))
if (beforeGuestStart) return
val scrubbed = scrubTree(record) { stopWhenSessionStarts && SessionState.running }
val marked = markScrubbed(scrubbed)
if (scrubbed.deferred) break
if (!marked) continue
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
@@ -25,6 +25,8 @@ object SessionFiles {
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
"usr/local/bin/droiddeck-library-perms" to "usr/local/bin/droiddeck-library-perms",
"usr/local/bin/droiddeck-ime" to "usr/local/bin/droiddeck-ime",
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
@@ -32,7 +34,7 @@ object SessionFiles {
/** 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) }
if (DesktopCatalog.desktopInstalled(root) && !DesktopCatalog.desktopFromPack(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
WinComponents.publish(context)
@@ -70,10 +72,21 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-launch" to "usr/local/bin/droiddeck-steam-launch",
"usr/local/bin/droiddeck-desktop-games" to "usr/local/bin/droiddeck-desktop-games",
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-downloads" to "usr/local/bin/droiddeck-downloads",
"usr/local/bin/droiddeck-root" to "usr/local/bin/droiddeck-root",
"usr/local/bin/droiddeck-text" to "usr/local/bin/droiddeck-text",
"usr/local/bin/droiddeck-snapd" to "usr/local/bin/droiddeck-snapd",
"usr/local/bin/droiddeck-emulators" to "usr/local/bin/droiddeck-emulators",
"usr/local/bin/droiddeck-snap-run" to "usr/local/bin/droiddeck-snap-run",
"usr/local/bin/droiddeck-snap-confine" to "usr/local/bin/droiddeck-snap-confine",
"usr/local/bin/droiddeck-snapctl" to "usr/local/bin/droiddeck-snapctl",
"usr/local/bin/droiddeck-systemd-run" to "usr/local/bin/systemd-run",
"usr/local/bin/droiddeck-snap-cli" to "usr/local/bin/snap",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
"usr/local/bin/droiddeck-gog-maintenance" to "usr/local/bin/droiddeck-gog-maintenance",
"usr/local/bin/droiddeck-battlenet" to "usr/local/bin/droiddeck-battlenet",
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
@@ -234,6 +247,7 @@ object SessionFiles {
Log.e(TAG, "could not write steam-compatibility-label")
}
writeCjkFontConfig(root, etc)
writeDesktopStrings(context, File(etc, "droiddeck").apply { mkdirs() })
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
@@ -241,6 +255,12 @@ object SessionFiles {
stagedSocket.delete()
Log.e(TAG, "could not write bwrap-socket")
}
val stagedRoot = File(droiddeckEtc, "root-socket.staged")
if (!FileUtils.writeString(stagedRoot, com.droiddeck.launcher.runtime.RootSpawner.socket(context) + "\n")
|| !stagedRoot.renameTo(File(droiddeckEtc, "root-socket"))) {
stagedRoot.delete()
Log.e(TAG, "could not write root-socket")
}
BootMovie.syncFromSteam(context)
val startupMovie = BootMovie.prepare(context)
@@ -270,6 +290,7 @@ object SessionFiles {
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
)
if (startsSession) disableIdleSleep(home)
if (!done) allDone = false
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
@@ -508,6 +529,43 @@ object SessionFiles {
}
}
private val IDLE_SLEEP_TIMERS = listOf("IdleSuspendBatterySeconds", "IdleSuspendACSeconds")
/**
* Sets Steam's Power > "Sleep after" (battery and plugged in) to Disabled at every session start: Android
* owns the device's sleep, and a client that suspends inside the session only shows a black screen. Dimming stays the user's. Runs
* only before the client starts, which rewrites its config on exit.
*/
internal fun disableIdleSleep(steamHome: File): Boolean {
val config = File(steamHome, "config/config.vdf")
val text = runCatching { config.readText() }.getOrNull() ?: return false
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val steam = findSteamBlock(text) ?: return false
val system = findObject(text, "system", steam.open + 1, steam.close)
val updated = if (system != null) {
val block = text.substring(system.keyStart, system.close + 1)
text.replaceRange(system.keyStart, system.close + 1, IDLE_SLEEP_TIMERS.fold(block) { acc, key -> setScalar(acc, key, "0", 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 == ' ' } + "\t"
val section = (listOf(indent + "\"system\"", indent + "{") +
IDLE_SLEEP_TIMERS.map { "$indent\t\"$it\"\t\t\"0\"" } + (indent + "}")).joinToString(newline) + newline
text.substring(0, lineStart) + section + text.substring(lineStart)
}
val staged = File(config.parentFile, config.name + ".staged")
return try {
if (updated != text) {
staged.writeText(updated)
if (!staged.renameTo(config)) { staged.delete(); return false }
}
true
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not disable Steam's idle sleep", 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
@@ -585,6 +643,28 @@ object SessionFiles {
* The fonts themselves are bind-mounted by LinuxRuntime.bindSystemFonts() into
* /usr/local/share/fonts/android; here we only need to point fontconfig at them.
*/
private val DESKTOP_STRINGS = listOf(
"desktop_restore_title" to R.string.desktop_restore_title,
"desktop_restore_body" to R.string.desktop_restore_body,
"desktop_restore_done_title" to R.string.desktop_restore_done_title,
"desktop_restore_done_body" to R.string.desktop_restore_done_body,
"desktop_restore_failed_title" to R.string.desktop_restore_failed_title,
"desktop_restore_failed_body" to R.string.desktop_restore_failed_body,
)
private fun writeDesktopStrings(context: Context, dir: File) {
val text = DESKTOP_STRINGS.joinToString("") { (key, id) ->
key + "=" + context.getString(id).replace("\\", "\\\\").replace("%", "%%").replace("\n", "\\n") + "\n"
}
val target = File(dir, "strings")
if (FileUtils.readString(target) == text) return
val staged = File(dir, "strings.staged")
if (!FileUtils.writeString(staged, text) || !staged.renameTo(target)) {
staged.delete()
Log.e(TAG, "could not write desktop strings")
}
}
private fun writeCjkFontConfig(root: File, etc: File) {
try {
val confDir = File(etc, "fonts/conf.d").apply { mkdirs() }
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.session
enum class SessionLaunchFailure {
STEAM_ILLEGAL_INSTRUCTION,
VULKAN_DEVICE_MISSING;
companion object {
private val ANSI = Regex("\u001b\\[[0-?]*[ -/]*[@-~]")
private val STEAM_EXIT = Regex("== steam exited rc=(\\d+)")
fun find(lines: Sequence<String>): SessionLaunchFailure? {
var steamStarted = false
var illegalInstruction = false
var missingDevice = false
lines.forEach { raw ->
val line = ANSI.replace(raw, "").trim()
when {
line.startsWith("== droiddeck-session ") -> {
steamStarted = false
illegalInstruction = false
missingDevice = false
}
line.startsWith("== STEP ") && line.contains("starting the Steam client") -> {
steamStarted = true
illegalInstruction = false
}
STEAM_EXIT.matches(line) && steamStarted ->
illegalInstruction = STEAM_EXIT.matchEntire(line)?.groupValues?.get(1) == "132"
line.startsWith("[gamescope]") && line.contains("vulkan:") -> when {
line.contains("failed to find physical device") -> missingDevice = true
line.contains("selecting physical device") -> missingDevice = false
}
}
}
return when {
illegalInstruction -> STEAM_ILLEGAL_INSTRUCTION
missingDevice -> VULKAN_DEVICE_MISSING
else -> null
}
}
}
}
@@ -44,6 +44,10 @@ object SessionPaths {
fun current(): File? = dir
/** Whether the current session folder will survive teardown. No session means the next one can retain logs. */
fun currentSessionLogsRetained(context: Context): Boolean =
dir?.let { it.parentFile == SessionFiles.logDirectory(context) } ?: true
fun lastEnded(): File? = ended?.takeIf { it.isDirectory }
@Synchronized
@@ -21,6 +21,8 @@ object SessionPrefs {
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
private const val SLOW_USB_WARNING_OFF = "slowUsbWarningOff"
private const val SLOW_USB_DISMISSED = "slowUsbDismissed"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
@@ -84,6 +86,32 @@ object SessionPrefs {
prefs(context).edit().putBoolean("animations", on).apply()
}
fun dualScreenMode(context: Context): String =
prefs(context).getString("dualScreenMode", null)?.takeIf { it in com.droiddeck.launcher.input.DualScreen.modes }
?: com.droiddeck.launcher.input.DualScreen.MODE_AUTO
fun setDualScreenMode(context: Context, mode: String) {
prefs(context).edit().putString("dualScreenMode", mode).apply()
}
fun secondScreenPanel(context: Context): String? = prefs(context).getString("secondScreenPanel", null)
fun setSecondScreenPanel(context: Context, mode: String) {
prefs(context).edit().putString("secondScreenPanel", mode).apply()
}
fun dualScreenLauncher(context: Context): Boolean = prefs(context).getBoolean("dualScreenLauncher", true)
fun setDualScreenLauncher(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("dualScreenLauncher", on).apply()
}
fun dualScreenSessionControls(context: Context): Boolean = prefs(context).getBoolean("dualScreenSessionControls", true)
fun setDualScreenSessionControls(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("dualScreenSessionControls", on).apply()
}
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
fun setGameStoresEnabled(context: Context, on: Boolean) {
@@ -125,6 +153,30 @@ object SessionPrefs {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
}
fun libraryCardLevel(context: Context): Int = prefs(context).getInt("libraryCardLevel", 2)
fun setLibraryCardLevel(context: Context, level: Int) {
prefs(context).edit().putInt("libraryCardLevel", level).apply()
}
fun libraryShowTitle(context: Context): Boolean = prefs(context).getBoolean("libraryShowTitle", true)
fun setLibraryShowTitle(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("libraryShowTitle", show).apply()
}
fun libraryShowSize(context: Context): Boolean = prefs(context).getBoolean("libraryShowSize", true)
fun setLibraryShowSize(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("libraryShowSize", show).apply()
}
fun railLabels(context: Context): Boolean = prefs(context).getBoolean("railLabels", false)
fun setRailLabels(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("railLabels", show).apply()
}
fun libraryShowUninstalled(context: Context): Boolean = prefs(context).getBoolean("libraryShowUninstalled", false)
fun setLibraryShowUninstalled(context: Context, show: Boolean) {
@@ -195,7 +247,7 @@ object SessionPrefs {
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), false)
fun setHudEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(hudKey(context), on).apply()
@@ -233,6 +285,20 @@ object SessionPrefs {
prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply()
}
fun fusionHudConfig(context: Context): String? = prefs(context).getString("fusionHud", null)
fun setFusionHudConfig(context: Context, config: String) {
prefs(context).edit().putString("fusionHud", config).apply()
}
fun fusionHudPosition(context: Context): Pair<Float, Float>? =
prefs(context).getString("fusionHudPos", null)?.split(',')?.mapNotNull { it.toFloatOrNull() }
?.takeIf { it.size == 2 }?.let { it[0] to it[1] }
fun setFusionHudPosition(context: Context, x: Float, y: Float) {
prefs(context).edit().putString("fusionHudPos", "$x,$y").apply()
}
const val HUD_COLOR_MINT = "mint"
val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet")
val HUD_OPACITY_RANGE = 30..100
@@ -249,6 +315,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean("lowLatency", on).apply()
}
fun evdevMouse(context: Context): Boolean = prefs(context).getBoolean("evdevMouse", false)
fun setEvdevMouse(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("evdevMouse", on).apply()
}
fun directCursor(context: Context): Boolean = prefs(context).getBoolean("directCursor", false)
fun setDirectCursor(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("directCursor", on).apply()
}
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
fun setBackActionsInverted(context: Context, inverted: Boolean) {
@@ -603,6 +681,7 @@ object SessionPrefs {
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
fun vsyncThread(context: Context): Boolean = prefs(context).getBoolean("vsyncThread", true)
fun renderOnCommit(context: Context): Boolean = prefs(context).getBoolean("renderOnCommit", true)
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
@@ -630,8 +709,74 @@ object SessionPrefs {
fun logsEnabled(context: Context): Boolean =
prefs(context).getBoolean("logs", true)
fun setLogsEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("logs", on).apply()
fun setLogsEnabled(context: Context, on: Boolean): Boolean = synchronized(GameEnvironmentStore) {
setLogPreference(
context,
key = "logs",
oldValue = logsEnabled(context),
newValue = on,
newEffective = on && gameDebugLogsEnabled(context) && SessionPaths.currentSessionLogsRetained(context),
)
}
/** Extra Proton, Wine and graphics diagnostics for the next game launch; off unless chosen. */
fun gameDebugLogsEnabled(context: Context): Boolean =
prefs(context).getBoolean("gameDebugLogsEnabled", false)
fun setGameDebugLogsEnabled(context: Context, on: Boolean): Boolean = synchronized(GameEnvironmentStore) {
setLogPreference(
context,
key = "gameDebugLogsEnabled",
oldValue = gameDebugLogsEnabled(context),
newValue = on,
newEffective = logsEnabled(context) && on && SessionPaths.currentSessionLogsRetained(context),
)
}
private fun setLogPreference(
context: Context,
key: String,
oldValue: Boolean,
newValue: Boolean,
newEffective: Boolean,
): Boolean {
if (oldValue == newValue) return true
val saved = prefs(context)
val oldEffective = logsEnabled(context) && gameDebugLogsEnabled(context) &&
SessionPaths.currentSessionLogsRetained(context)
fun publish(value: Boolean) = GameEnvironmentStore.publish(context, gameDebugLogsOverride = value)
if (oldEffective == newEffective) {
if (saved.edit().putBoolean(key, newValue).commit()) return true
saved.edit().putBoolean(key, oldValue).commit()
return false
}
if (oldEffective && !newEffective) {
try {
publish(false)
} catch (e: Exception) {
android.util.Log.e("GameEnvironment", "Could not disable game diagnostics", e)
return false
}
if (saved.edit().putBoolean(key, newValue).commit()) return true
saved.edit().putBoolean(key, oldValue).commit()
runCatching { publish(oldEffective) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not restore game diagnostics", it) }
return false
}
if (!saved.edit().putBoolean(key, newValue).commit()) {
saved.edit().putBoolean(key, oldValue).commit()
return false
}
return try {
publish(newEffective)
true
} catch (e: Exception) {
saved.edit().putBoolean(key, oldValue).commit()
runCatching { publish(oldEffective) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not restore game diagnostics", it) }
android.util.Log.e("GameEnvironment", "Could not update game diagnostics", e)
false
}
}
fun battleNetNoSteamSet(context: Context): Boolean = prefs(context).getBoolean("battleNetNoSteamSet", false)
@@ -942,12 +1087,26 @@ object SessionPrefs {
/** Compositor frame limit that releases buffers to the game unpaced. */
const val FPS_UNPACED = -1
/** The drawer's frame limit slider: its first stop is Off. */
const val FPS_LIMIT_OFF = 15
const val FPS_LIMIT_MAX = 120
/**
* Buffers go back to the game as soon as they are replaced instead of once per refresh;
* Steam's Frame Limit in the Quick Access menu caps games. Mirroring still follows the glasses' refresh rate.
* Steam's Frame Limit in the Quick Access menu caps Steam games, and the drawer's limit caps games
* launched without Steam. Mirroring still follows the glasses' refresh rate.
*/
fun compositorFpsLimit(mirrorRefreshLimit: Int): Int =
if (mirrorRefreshLimit <= 0) FPS_UNPACED else mirrorRefreshLimit
const val PIP_FPS = 30
fun compositorFpsLimit(mirrorRefreshLimit: Int, sessionLimit: Int = 0, pip: Boolean = false): Int {
val limit = when {
mirrorRefreshLimit <= 0 && sessionLimit <= 0 -> FPS_UNPACED
mirrorRefreshLimit <= 0 -> sessionLimit
sessionLimit <= 0 -> mirrorRefreshLimit
else -> minOf(mirrorRefreshLimit, sessionLimit)
}
return if (pip && mirrorRefreshLimit <= 0 && (limit <= 0 || limit > PIP_FPS)) PIP_FPS else limit
}
val upscalerChoices = listOf(
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
@@ -1128,7 +1287,7 @@ object SessionPrefs {
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
else prefMode(mode)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", true)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
@@ -1171,7 +1330,8 @@ object SessionPrefs {
}
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
prefs(context).getString("suspendPolicy.${prefMode(mode)}",
if (mode == SessionService.MODE_STEAM) SUSPEND_NATIVE else SUSPEND_MANUAL)
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
@@ -1197,6 +1357,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
}
fun slowUsbWarningOff(context: Context): Boolean = prefs(context).getBoolean(SLOW_USB_WARNING_OFF, false)
fun setSlowUsbWarningOff(context: Context) {
prefs(context).edit().putBoolean(SLOW_USB_WARNING_OFF, true).apply()
}
fun slowUsbDismissed(context: Context): Set<String> = prefs(context).getStringSet(SLOW_USB_DISMISSED, null)?.toSet().orEmpty()
fun setSlowUsbDismissed(context: Context, keys: Set<String>) {
prefs(context).edit().putStringSet(SLOW_USB_DISMISSED, keys.toSet()).apply()
}
fun steamDownloadsInBackground(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
@@ -91,6 +91,7 @@ class SessionService : Service() {
private var screenOn = true
private var sessionDisplayId = Display.DEFAULT_DISPLAY
private var manualPauseRequested = false
private var manualHold = false
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
@@ -274,7 +275,11 @@ class SessionService : Service() {
val hold = intent?.getBooleanExtra(EXTRA_HOLD, false) == true
if (hold == signInHold) return START_NOT_STICKY
signInHold = hold
if (hold) manualPauseRequested = false
if (hold) {
manualPauseRequested = false
manualHold = false
SessionState.manualPause = false
}
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
@@ -304,6 +309,14 @@ class SessionService : Service() {
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_PAUSE_NOW -> {
if (!SessionState.running || SessionState.suspended || signInHold) return START_NOT_STICKY
manualHold = true
SessionState.manualPause = true
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_RESUME -> {
resumeSession()
return START_NOT_STICKY
@@ -321,6 +334,7 @@ class SessionService : Service() {
return START_NOT_STICKY
}
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
SessionState.fpsLimit = 0
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
activityVisible = true
@@ -346,6 +360,8 @@ class SessionService : Service() {
SessionState.resumeRequested = false
SessionState.resuming = false
SessionState.resumeFailed = false
manualHold = false
SessionState.manualPause = false
SessionState.pipActive = false
pipTask = false
SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM)
@@ -385,10 +401,10 @@ class SessionService : Service() {
}
private fun collectSessionArtifacts(dir: File, steamArch: String?) {
SessionLogCapture.stopFor(dir)
try {
SessionArtifacts.collect(this, dir, "session stopped", steamArch)
} finally {
SessionLogCapture.stopFor(dir)
SessionPaths.release(this, dir)
}
}
@@ -461,13 +477,20 @@ class SessionService : Service() {
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)
SessionArtifacts.finishAbandoned(this, beforeGuestStart = true)
VacDiagnostics.arm(this)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
com.droiddeck.launcher.stores.gog.GogComet.begin()
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
val sessionDir = openSessionFolder()
try {
GameEnvironmentStore.publish(this)
} catch (e: Exception) {
Log.e(TAG, "could not publish the game environment", e)
stopSession(-1)
return
}
val sessionLog = File(sessionDir, "session.log")
val prootLine = "== proot: ${LinuxRuntime.prootBinary(this).path} (${LinuxRuntime.prootSource(this)})"
Log.i(TAG, prootLine)
@@ -513,10 +536,6 @@ class SessionService : Service() {
// gamescope's WSI layer and winex11 reuse their per-present window checks for this long.
guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=100")
guest.add("WINE_OFFSCREEN_CHECK_MS=100")
// gamescope turns buffers around slower than an uncapped game draws; more images keep it drawing.
// The WSI layer goes back to its default count while Steam's Frame Limit holds the game.
guest.add("GAMESCOPE_WSI_MIN_IMAGE_COUNT=8")
guest.add("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
@@ -524,6 +543,7 @@ class SessionService : Service() {
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
SessionState.secondScreenAutoOpened = false
deckBinds = emptyList()
inputSysfs = null
if (controllersOn) {
@@ -697,6 +717,7 @@ class SessionService : Service() {
if (!pulse.setMicSuspended(false)) Log.w(TAG, "could not resume microphone source")
},
zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"),
clockControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-clock"),
pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis ->
Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " +
"resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB")
@@ -734,7 +755,7 @@ class SessionService : Service() {
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
guest.add("BL_STEAM_LANGUAGE=" + SteamLanguage.forLocale(
@@ -858,7 +879,7 @@ class SessionService : Service() {
}
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
FakeInputWriter.prepareRingSlots(fakeInputDir, 5)
val builtinPad = SessionPrefs.builtinPad(this)
val hiddenPad = SessionState.mode == MODE_STEAM && !builtinPad &&
!com.droiddeck.launcher.input.PadBridge.anyControllerConnected()
@@ -1016,7 +1037,7 @@ class SessionService : Service() {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRoots(this)) {
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRootsForXodus(this)) {
if (host.isDirectory && host.canRead()) {
File(LinuxRuntime.rootDir(this), guest.removePrefix("/")).mkdirs()
binds.add(host.path + ":" + guest)
@@ -1332,6 +1353,8 @@ class SessionService : Service() {
if (panelVisible) activityVisible = true
screenOn = readScreenOn()
manualPauseRequested = false
manualHold = false
SessionState.manualPause = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
@@ -1539,12 +1562,12 @@ class SessionService : Service() {
manualPauseRequested = false
}
val controller = suspendController ?: return
if (downloadsBlockingSuspend) acquireLocks()
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
if (downloadsBlockingSuspend && !manualHold) acquireLocks()
val steamPause = SessionState.mode == MODE_STEAM && (manualHold || !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 &&
@@ -1552,7 +1575,7 @@ class SessionService : Service() {
prepareNativeSleep()
}
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
val shouldSuspend = steamSleepToken != null || (manualHold && prepared) || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
@@ -1600,6 +1623,8 @@ class SessionService : Service() {
SessionEvents.record("session.suspend_cancelled")
} else {
suspendAttemptFailed = true
manualHold = false
SessionState.manualPause = false
Log.w(TAG, "could not confirm that the session stopped")
}
}
@@ -1648,7 +1673,7 @@ class SessionService : Service() {
}
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (!manualHold && suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
@@ -1706,42 +1731,48 @@ class SessionService : Service() {
}.onFailure { Log.w(TAG, "could not request Steam wake", it) }
}
private fun finishSessionStop(status: Int, stoppedGen: Int) {
private fun finishSessionStop(status: Int, stoppedGen: Int, afterStopped: () -> Unit = {}) {
mainHandler.post {
if (stoppedGen != sessionGen || SessionState.running) {
Log.i(TAG, "session $stoppedGen finished stopping after a new one started")
return@post
try {
if (stoppedGen != sessionGen || SessionState.running) {
Log.i(TAG, "session $stoppedGen finished stopping after a new one started")
return@post
}
SessionState.suspended = false
SessionState.resumeRequested = false
SessionState.resuming = false
SessionState.resumeFailed = false
manualHold = false
SessionState.manualPause = false
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
com.droiddeck.launcher.agent.AgentGuest.stop()
com.droiddeck.launcher.stores.StoreLaunchRequests.stop()
Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start()
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
releaseLocks()
MemoryLog.stop()
com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(applicationContext)
if (status == 0) {
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
} else {
val code = SessionState.failureCode ?: "GUEST_EXIT"
val message = SessionState.failureMessage ?: "Session guest exited with status $status"
val failureStatus = SessionState.failureStatus ?: status
SessionEvents.fail(code, message, failureStatus)
}
SessionState.steamVr = false
com.droiddeck.launcher.glasses.Glasses.cancelSteamVrStart()
com.droiddeck.launcher.glasses.Glasses.setVrActive(false)
com.droiddeck.launcher.glasses.Glasses.set3dForVr(false)
SessionState.notifyEnded(status)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} finally {
afterStopped()
}
SessionState.suspended = false
SessionState.resumeRequested = false
SessionState.resuming = false
SessionState.resumeFailed = false
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
com.droiddeck.launcher.agent.AgentGuest.stop()
com.droiddeck.launcher.stores.StoreLaunchRequests.stop()
Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start()
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
releaseLocks()
MemoryLog.stop()
com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(applicationContext)
if (status == 0) {
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
} else {
val code = SessionState.failureCode ?: "GUEST_EXIT"
val message = SessionState.failureMessage ?: "Session guest exited with status $status"
val failureStatus = SessionState.failureStatus ?: status
SessionEvents.fail(code, message, failureStatus)
}
SessionState.steamVr = false
com.droiddeck.launcher.glasses.Glasses.cancelSteamVrStart()
com.droiddeck.launcher.glasses.Glasses.setVrActive(false)
com.droiddeck.launcher.glasses.Glasses.set3dForVr(false)
SessionState.notifyEnded(status)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
@@ -1772,7 +1803,9 @@ class SessionService : Service() {
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
SessionState.running = false
SessionPaths.take() to SessionState.steamClientArch
val ended = SessionPaths.take()
if (ended != null) SessionArtifacts.collectionStarted(ended)
ended to SessionState.steamClientArch
}
SessionState.micAvailable = false
SessionState.micEnabled = false
@@ -1801,8 +1834,6 @@ class SessionService : Service() {
suspendController = null
if (ended != null) {
VacDiagnostics.snapshot(this, ended)
SessionArtifacts.collectionStarted(ended)
Thread({ collectSessionArtifacts(ended, endedArch) }, "session-collect").start()
}
components.reversed().forEach {
try {
@@ -1812,6 +1843,9 @@ class SessionService : Service() {
}
}
components.clear()
val collectEnded = {
if (ended != null) Thread({ collectSessionArtifacts(ended, endedArch) }, "session-collect").start()
}
FakeInputWriter.releaseAllRingSlots()
com.droiddeck.launcher.input.SteamTouchDevice.release()
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
@@ -1823,13 +1857,13 @@ class SessionService : Service() {
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
killGuestLeftovers()
finishSessionStop(status, stoppedGen)
finishSessionStop(status, stoppedGen, collectEnded)
if (launcherEnded) importLauncherGames()
}, "session-teardown").start()
} else {
Thread({
com.droiddeck.launcher.stores.gog.GogComet.end(stoppedSession)
finishSessionStop(status, stoppedGen)
finishSessionStop(status, stoppedGen, collectEnded)
if (launcherEnded) importLauncherGames()
}, "gog-session-stop").start()
}
@@ -1847,6 +1881,9 @@ class SessionService : Service() {
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (rootIntent?.component?.className in setOf(
com.droiddeck.launcher.ui.DualScreenActivity::class.java.name,
com.droiddeck.launcher.ui.SessionTypingActivity::class.java.name)) return
if (pipTask || xrActivityVisible) {
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
activityVisible = false
@@ -2068,6 +2105,7 @@ class SessionService : Service() {
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW"
private const val ACTION_PAUSE_NOW = "com.droiddeck.launcher.PAUSE_NOW"
private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN"
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
@@ -2142,6 +2180,7 @@ class SessionService : Service() {
fun stop(context: Context) {
if (!SessionState.running) {
SessionState.stopRequested = true
SessionState.installCancellation?.cancel()
return
}
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
@@ -2187,6 +2226,10 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SIGN_IN_HOLD).putExtra(EXTRA_HOLD, hold))
}
fun pause(context: Context) {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PAUSE_NOW))
}
fun resume(context: Context) {
if (!SessionState.running) return
if (SessionState.suspended) SessionState.resumeRequested = true
@@ -37,6 +37,9 @@ object SessionState {
@Volatile
var installing: String? = null
@Volatile
var installCancellation: com.droiddeck.launcher.core.Downloader.Cancellation? = null
@Volatile
var stopRequested = false
@@ -53,6 +56,7 @@ object SessionState {
var resuming by mutableStateOf(false)
var resumeFailures by mutableIntStateOf(0)
var resumeFailed by mutableStateOf(false)
var manualPause by mutableStateOf(false)
@Volatile var pipActive = false
@Volatile
var program: String? = null
@@ -87,6 +91,7 @@ object SessionState {
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@Volatile var secondScreenDisplay = -1
@Volatile var secondScreenAutoOpened = false
@Volatile
var mode = "steam"
@@ -103,6 +108,10 @@ object SessionState {
@Volatile
var upscaleRatio: Float = 0f
/** Frame limit chosen in the drawer for a game launched without Steam; 0 is off. */
@Volatile
var fpsLimit: Int = 0
@Volatile
var firstFrameSeen = false
@@ -3,6 +3,9 @@ package com.droiddeck.launcher.session
import android.os.Process
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
@@ -19,6 +22,8 @@ internal class SessionSuspendController(
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
private val zramControl: File? = null,
private val clockControl: File? = null,
private val nowNanos: () -> Long = System::nanoTime,
private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit =
{ _, _, _, _, _, _ -> },
) {
@@ -43,6 +48,7 @@ internal class SessionSuspendController(
@Volatile private var closing = false
@Volatile private var cancelled = false
private var frozen = emptyList<ProcessRecord>()
private var frozenSince: Long? = null
fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) {
cancelled = false
@@ -93,6 +99,7 @@ internal class SessionSuspendController(
} catch (_: Exception) {
} finally {
frozen = records.values.toList()
if (complete && frozenSince == null) frozenSince = nowNanos()
if (!complete) {
if (restoreSafely(frozen)) frozen = emptyList()
runAudio(resumeAudio)
@@ -111,6 +118,7 @@ internal class SessionSuspendController(
fun resume(completion: (Boolean) -> Unit) {
submit({
val records = frozen
advanceClock()
runAudio(resumeAudio)
val resumed = restoreSafely(records)
if (resumed) frozen = emptyList()
@@ -121,6 +129,7 @@ internal class SessionSuspendController(
fun closeAndResume(completion: () -> Unit) {
closing = true
submit({
advanceClock()
if (restoreSafely(frozen)) frozen = emptyList()
completion()
executor.shutdown()
@@ -135,6 +144,23 @@ internal class SessionSuspendController(
}
}
private fun advanceClock() {
val since = frozenSince ?: return
frozenSince = null
val control = clockControl?.takeIf { it.isFile && it.length() >= Long.SIZE_BYTES } ?: return
try {
RandomAccessFile(control, "rw").use { file ->
val bytes = ByteArray(Long.SIZE_BYTES)
file.readFully(bytes)
val shift = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).long.coerceAtLeast(0L) +
(nowNanos() - since).coerceAtLeast(0L)
file.seek(0L)
file.write(ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putLong(shift).array())
}
} catch (_: IOException) {
}
}
private fun runAudio(action: () -> Unit) {
try {
action()
@@ -12,16 +12,17 @@ object SettingsReset {
internal val SESSION_KEYS = setOf(
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
"dualScreenMode", "dualScreenLauncher", "dualScreenSessionControls",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"storesInstallLanguage",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "fusionHud", "fusionHudPos", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
"stretch16x9", "picturePosition",
"stretch16x9", "picturePosition", "railLabels",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
"bootMovieApplyNext", "bootMovieSteamSeen",
@@ -37,6 +38,7 @@ object SettingsReset {
internal fun isSessionSetting(key: String) = key in SESSION_KEYS || SESSION_PREFIXES.any { key.startsWith(it) }
fun resetAll(context: Context, gameProfiles: Boolean, gameScopes: Collection<String> = emptyList()): Boolean {
if (!SessionPrefs.setGameDebugLogsEnabled(context, false)) return false
var ok = true
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
session.edit().apply { session.all.keys.filter { isSessionSetting(it) }.forEach { remove(it) } }.commit()
@@ -0,0 +1,26 @@
package com.droiddeck.launcher.session
import org.json.JSONObject
import java.io.File
object SteamImportPrompt {
data class Ask(val token: String, val games: List<String>)
private fun folder(root: File) = File(root, "root/.cache/droiddeck")
fun read(root: File): Ask? {
val text = runCatching { File(folder(root), "steam-import.json").readText() }.getOrNull() ?: return null
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
val token = json.optString("token").takeIf { it.isNotEmpty() } ?: return null
val list = json.optJSONArray("games")
val games = if (list == null) emptyList() else (0 until list.length()).mapNotNull { list.optString(it).takeIf(String::isNotBlank) }
return Ask(token, games)
}
fun answer(root: File, token: String, now: Boolean): Boolean = runCatching {
val dir = folder(root).apply { mkdirs() }
val temp = File(dir, "steam-import.answer.tmp")
temp.writeText(JSONObject().put("token", token).put("choice", if (now) "now" else "later").toString())
temp.renameTo(File(dir, "steam-import.answer"))
}.getOrDefault(false)
}
@@ -0,0 +1,122 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.storage.StorageManager
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
object UsbLink {
data class Link(val device: String, val mbps: Double, val name: String, val portMbps: Double?, val plug: String = "")
class SlowDrive(val key: String, val name: String, val mbps: Double, val portMbps: Double?) {
val portLimited get() = portMbps != null && portMbps < FAST_MBPS
}
const val FAST_MBPS = 5000.0
fun markShown(context: Context, drive: SlowDrive) = synchronized(this) {
SessionPrefs.setSlowUsbDismissed(context, SessionPrefs.slowUsbDismissed(context) + drive.key)
}
fun stillDue(context: Context, drive: SlowDrive) =
!SessionPrefs.slowUsbWarningOff(context) && drive.key !in SessionPrefs.slowUsbDismissed(context)
fun slowLibrary(context: Context): SlowDrive? {
if (SessionPrefs.slowUsbWarningOff(context)) return null
val mounts = runCatching { File("/proc/self/mounts").readText() }.getOrNull() ?: return null
val sm = context.getSystemService(StorageManager::class.java)
val boot = runCatching { android.provider.Settings.Global.getInt(context.contentResolver, android.provider.Settings.Global.BOOT_COUNT) }.getOrDefault(0)
val drives = libraries(context).mapNotNull { path ->
val link = forPath(path, mounts) ?: return@mapNotNull null
if (link.mbps >= FAST_MBPS) return@mapNotNull null
val volume = runCatching { sm?.getStorageVolume(File(path))?.getDescription(context) }.getOrNull()
SlowDrive(dismissKey(boot, link), link.name.ifBlank { volume.orEmpty() }.ifBlank { File(path).name }, link.mbps, link.portMbps)
}
return synchronized(this) {
val dismissed = SessionPrefs.slowUsbDismissed(context)
val current = dismissed.intersect(drives.map { it.key }.toSet())
if (current != dismissed) SessionPrefs.setSlowUsbDismissed(context, current)
drives.firstOrNull { it.key !in current }
}
}
internal fun dismissKey(boot: Int, link: Link) = "$boot/${link.plug}/${link.device}@${link.mbps}"
private fun libraries(context: Context): List<String> {
val pref = SessionPrefs.gameStorage(context)
val storage = when (pref) {
SessionPrefs.GAME_STORAGE_OFF -> null
"" -> GameStorage.effective(context)?.path
else -> pref
}
val folders = runCatching { AddedGames.roots(context).map { it.host.absolutePath } }.getOrDefault(emptyList())
return (listOfNotNull(storage) + folders).map(::storagePath).filter { external(it) }.distinct()
}
internal fun storagePath(path: String) = if (path.startsWith("/mnt/media_rw/")) "/storage/" + path.removePrefix("/mnt/media_rw/") else path
internal fun external(path: String) = path.startsWith("/storage/") && !path.startsWith("/storage/emulated/") && !path.startsWith("/storage/self/")
fun forPath(path: String, mounts: String, sys: File = File("/sys")): Link? {
val device = mountDevice(rawPath(path), mounts) ?: return null
val block = blockDir(device, sys) ?: return null
val usb = usbDevice(block, sys) ?: return null
val mbps = readSpeed(usb) ?: return null
val name = listOf("manufacturer", "product").mapNotNull { read(File(usb, it)) }.joinToString(" ").trim()
val plug = listOf("busnum", "devnum").mapNotNull { read(File(usb, it)) }.joinToString("-")
return Link(device, mbps, name, portSpeed(usb, sys), plug)
}
internal fun rawPath(path: String) = if (external(path)) "/mnt/media_rw/" + path.removePrefix("/storage/") else path
internal fun mountDevice(path: String, mounts: String): String? {
var best: Pair<String, String>? = null
for (line in mounts.lineSequence()) {
val parts = line.split(' ')
if (parts.size < 2) continue
val point = parts[1].replace("\\040", " ")
if (path != point && !path.startsWith("${point.trimEnd('/')}/")) continue
if (best == null || point.length > best.first.length) best = point to parts[0]
}
val source = best?.second ?: return null
Regex(":(\\d+),(\\d+)$").find(source)?.let { return "${it.groupValues[1]}:${it.groupValues[2]}" }
return if (source.startsWith("/dev/block/") || source.startsWith("/dev/sd")) "name:" + source.substringAfterLast('/') else null
}
internal fun blockDir(device: String, sys: File): File? {
if (device.startsWith("name:")) return File(sys, "class/block/${device.removePrefix("name:")}").takeIf { it.exists() }?.canonicalFile
File(sys, "dev/block/$device").takeIf { it.exists() }?.let { return it.canonicalFile }
return File(sys, "class/block").listFiles()?.firstOrNull { read(File(it, "dev")) == device }?.canonicalFile
}
internal fun usbDevice(block: File, sys: File): File? {
val top = sys.canonicalFile
var dir: File? = block
while (dir != null && dir != top) {
if (File(dir, "speed").isFile && File(dir, "idVendor").isFile) return dir
dir = dir.parentFile
}
return null
}
private fun portSpeed(usb: File, sys: File): Double? {
val bus = read(File(usb, "busnum")) ?: return null
val hub = File(sys, "bus/usb/devices/usb$bus").takeIf { it.exists() }?.canonicalFile ?: return null
val controller = hub.parentFile ?: return null
return controller.listFiles { f -> f.name.matches(Regex("usb\\d+")) }?.mapNotNull { readSpeed(it) }?.maxOrNull()
}
private fun readSpeed(dir: File) = read(File(dir, "speed"))?.toDoubleOrNull()
private fun read(file: File) = runCatching { file.readText().trim() }.getOrNull()?.takeIf { it.isNotEmpty() }
fun generation(mbps: Double) = when {
mbps <= 12 -> "USB 1.1"
mbps < FAST_MBPS -> "USB 2.0"
mbps < 10000 -> "USB 3.2 Gen 1"
mbps < 20000 -> "USB 3.2 Gen 2"
else -> "USB 3.2 Gen 2x2"
}
fun megabytesPerSecond(mbps: Double) = (mbps / 8 * 0.7).toInt()
}
@@ -68,13 +68,13 @@ object VacDiagnostics {
}
}
fun collect(context: Context, dir: File) {
fun collect(context: Context, dir: File): Boolean {
val root = LinuxRuntime.rootDir(context)
val kept = File(dir, SNAPSHOT_MARKER).takeIf { it.isFile }
val marker = kept ?: File(root, MARKER)
val launchList = if (kept != null) File(dir, SNAPSHOT_LAUNCHES) else File(root, LAUNCHES)
if (!marker.isFile) return
try {
if (!marker.isFile) return true
return try {
val armed = marker.readLines().map { it.trim() }
val since = (armed.getOrNull(0)?.toLongOrNull() ?: 0L) * 1000 - 5_000
val arch = armed.getOrNull(1)?.takeIf { it.isNotEmpty() } ?: SessionPrefs.steamClientArch(context)
@@ -125,8 +125,10 @@ object VacDiagnostics {
File(dir, SNAPSHOT_MARKER).delete()
File(dir, SNAPSHOT_LAUNCHES).delete()
Log.i(TAG, "VAC diagnostics: ${consoles.size} console log(s), ${steamHits.size} client VAC line(s), into $dir")
true
} catch (e: Exception) {
Log.w(TAG, "collecting VAC diagnostics", e)
false
}
}
@@ -19,7 +19,7 @@ enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM
class GameUsage(
val appId: Int, val name: String, val art: File?, val library: String, val internal: Boolean, val lastPlayed: Long,
val game: Long, val prefix: Long, val shaders: Long, val workshop: Long, val shaderDir: File?,
val x64: Boolean = false,
val x64: Boolean = false, val prefixDir: File? = null,
) {
val data: Long get() = prefix + shaders + workshop
val total: Long get() = game + data
@@ -175,14 +175,16 @@ object StorageScan {
}
if (!m.installed) continue
val gameBytes = dir?.let { File(lib.root, "steamapps/common/$it") }?.takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val prefix = File(lib.data, "steamapps/compatdata/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val compatRoot = File(lib.data, "steamapps/compatdata").takeIf { it.isDirectory } ?: File(lib.root, "steamapps/compatdata")
val prefixDir = File(compatRoot, m.appId.toString()).takeIf { it.isDirectory }
val prefix = prefixDir?.let { walker.measure(it) } ?: 0
val shaderDir = File(lib.data, "steamapps/shadercache/${m.appId}").takeIf { it.isDirectory }
val shaders = shaderDir?.let { walker.measure(it) } ?: 0
val workshop = File(lib.root, "steamapps/workshop/content/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val known = art[lib.x64]?.get(m.appId)
games.add(GameUsage(
m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed,
gameBytes, prefix, shaders, workshop, shaderDir, lib.x64,
gameBytes, prefix, shaders, workshop, shaderDir, lib.x64, prefixDir,
))
}
}
@@ -140,6 +140,13 @@ object StorageState {
FileOps.delete(dir)
})
fun resetPrefix(context: Context, game: GameUsage) = act(context, "prefix-${game.appId}", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == game.appId && it.x64 == game.x64 && it.prefixDir?.path == game.prefixDir?.path }
?.prefixDir ?: error("prefix not found")
check(FileOps.delete(dir)) { "cannot delete $dir" }
true
})
fun removeLeftover(context: Context, l: Leftover) = act(context, "leftover-${l.appId}", { _, fresh ->
val leftover = fresh.leftovers.firstOrNull { it.appId == l.appId && it.prefix?.path == l.prefix?.path && it.shaders?.path == l.shaders?.path }
?: return@act false
@@ -63,7 +63,7 @@ class StoreGameSidecar(
val file = File(folder, FILE_NAME)
val tmp = File(folder, "$FILE_NAME.tmp")
tmp.writeText(toJson().toString(2))
if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) }
if (!tmp.renameTo(file)) java.nio.file.Files.move(tmp.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
companion object {
@@ -137,10 +137,7 @@ object StoreInstallRoot {
.filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) }
.map { root ->
val base = guestFor(context, root)
val guest = if (used.add(base)) base else {
val suffix = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
"$base-$suffix".also { used.add(it) }
}
val guest = if (used.add(base)) base else "$base-${pathKey(root)}".also { used.add(it) }
root to guest
}
}
@@ -168,15 +165,20 @@ object StoreInstallRoot {
/** Xodus has its own PRoot mount table and cannot see the selected Steam library's SD bind. */
fun externalRootsForXodus(context: Context): List<Pair<File, String>> {
val roots = externalRoots(context).toMutableList()
val roots = externalRoots(context)
val selectedLibrary = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) }
?.takeUnless { isInternal(context, it) }
if (selectedLibrary != null && roots.none { it.first.absolutePath == selectedLibrary.absolutePath }) {
roots += selectedLibrary to guestFor(context, selectedLibrary)
}
return roots
?.takeUnless { isInternal(context, it) } ?: return roots
return withSelectedLibrary(roots, selectedLibrary, guestFor(context, selectedLibrary))
}
internal fun withSelectedLibrary(roots: List<Pair<File, String>>, library: File, base: String): List<Pair<File, String>> {
if (roots.any { it.first.absolutePath == library.absolutePath }) return roots
val guest = if (roots.none { it.second == base }) base else "$base-${pathKey(library)}"
return roots + (library to guest)
}
private fun pathKey(root: File): String = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
fun externalBindSpecs(context: Context): List<String> = externalBindSpecs(externalRoots(context), LinuxRuntime.rootDir(context))
fun externalBindSpecsForXodus(context: Context): List<String> =
@@ -192,7 +194,7 @@ object StoreInstallRoot {
private fun guestFor(context: Context, root: File): String {
val sm = context.getSystemService(StorageManager::class.java)
val uuid = runCatching { sm?.getStorageVolume(root)?.uuid }.getOrNull()?.takeIf { it.isNotBlank() }?.replace(Regex("[^A-Za-z0-9-]"), "")
val key = uuid ?: "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
val key = uuid ?: pathKey(root)
return "$GUEST_EXTERNAL/$key"
}
@@ -254,6 +256,7 @@ object StoreInstallRoot {
val sidecar = StoreGameSidecar.read(folder)
when {
sidecar == null -> Unfinished(store, null, folder)
store == Store.GOG && sidecar.state == StoreGameSidecar.STATE_UPDATING -> null
!sidecar.isInstalled -> Unfinished(store, sidecar.id, folder)
else -> null
}
@@ -30,7 +30,12 @@ object StoreInstalls {
it.write(before.copy(state = StoreGameSidecar.STATE_UPDATING, installVersion = "").toJson().toString(2).toByteArray(Charsets.UTF_8))
it.fd.sync()
}
Files.move(stage.toPath(), StoreGameSidecar.file(folder).toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
val record = StoreGameSidecar.file(folder).toPath()
try {
Files.move(stage.toPath(), record, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (e: java.nio.file.AtomicMoveNotSupportedException) {
Files.move(stage.toPath(), record, StandardCopyOption.REPLACE_EXISTING)
}
} finally {
stage.delete()
}
@@ -81,7 +81,7 @@ object StoresState {
runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(app) }
val found = Store.entries.mapNotNull { store -> StoreAccounts.displayName(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) }
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled || it.store == Store.GOG && it.state == StoreGameSidecar.STATE_UPDATING }?.let { InstalledStoreGame(it, folder) }
} + runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetBackend.installedGames(app) }.getOrDefault(emptyList())
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
@@ -95,6 +95,7 @@ object StoresState {
engine = version
accountsLoaded = true
}
com.droiddeck.launcher.stores.gog.GogMaintenance.checkInstalled(app, onDisk)
}
}
}
@@ -191,7 +192,10 @@ object StoresState {
backend?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchCollections.clear()
if (store == Store.ITCH) {
com.droiddeck.launcher.stores.itch.ItchCollections.clear()
com.droiddeck.launcher.stores.itch.ItchUpdates.clear(app)
}
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); libraryAsked.remove(store)
@@ -282,6 +286,7 @@ object StoresState {
main.post {
installed = installed.filterNot { it.folder.absolutePath == game.folder.absolutePath }
uninstalling.remove(key)
if (game.sidecar.store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchUpdates.applied(app, key)
}
})
main.post(onDone)
@@ -31,6 +31,9 @@ data class DownloadEntry(
val stageItemsTotal: Int = 0,
val diskBytes: Long = 0L,
val stagesPassed: Int = 0,
val interruptible: Boolean = true,
val reusedBytes: Long = 0L,
val appliedBytes: Long = 0L,
) {
fun passed(s: DownloadStage): Boolean = state == DownloadState.INSTALLED || (stagesPassed and (1 shl s.ordinal)) != 0
@@ -21,6 +21,11 @@ object DownloadQueue {
fun diskSize(bytes: Long) = update(key) { if (bytes > 0) it.copy(diskBytes = bytes) else it }
fun progress(bytesDone: Long, bytesTotal: Long, detail: String? = null, @Suppress("UNUSED_PARAMETER") speedBps: Long = -1L) = measure(key, bytesDone, bytesTotal, detail?.let(::stripSpeed))
fun log(line: String) = StoresState.logLine(line)
fun interruptible(value: Boolean) = update(key) {
check(value || !cancelled.get()) { "The operation was paused before installer application" }
it.copy(interruptible = value)
}
fun maintenance(reusedBytes: Long, appliedBytes: Long) = update(key) { it.copy(reusedBytes = reusedBytes, appliedBytes = appliedBytes) }
}
private fun stripSpeed(s: String): String = s.replace(Regex("\\s+\\S+ [KMG]B/s\\s*$"), "").trimEnd()
@@ -166,12 +171,13 @@ object DownloadQueue {
fun pause(key: String) {
val item = synchronized(lock) { items[key] } ?: return
synchronized(lock) {
if (!item.entry.interruptible) return
if (item.entry.state == DownloadState.QUEUED) { item.entry = item.entry.copy(state = DownloadState.PAUSED, queuePosition = 0); renumber(); publishLocked(); return }
if (item.entry.state != DownloadState.RUNNING) return
item.pauseRequested = true
item.cancelled.set(true)
item.job?.cancel(deleteFiles = false)
}
item.cancelled.set(true)
item.job?.cancel(deleteFiles = false)
StoresState.logLine("paused \"${item.entry.name}\"")
}
@@ -204,17 +210,19 @@ object DownloadQueue {
val wasRunning: Boolean
synchronized(lock) {
item = items[key] ?: return
if (!item.entry.interruptible) return
wasRunning = item.entry.state == DownloadState.RUNNING
if (wasRunning) {
item.cancelled.set(true)
item.job?.cancel(deleteFiles = true)
}
if (!wasRunning) {
item.entry = item.entry.copy(state = DownloadState.CANCELLED, queuePosition = 0, finishedAt = System.currentTimeMillis())
renumber()
publishLocked()
}
}
if (wasRunning) {
item.cancelled.set(true)
item.job?.cancel(deleteFiles = true)
} else {
if (!wasRunning) {
Thread({ runCatching { item.factory?.invoke()?.cancel(deleteFiles = true) } }, "store-dl-cancel").start()
}
StoresState.logLine("cancelled \"${item.entry.name}\"")
@@ -45,7 +45,7 @@ object SavedQueue {
val f = File(context.filesDir, FILE)
val tmp = File(context.filesDir, "$FILE.tmp")
tmp.writeText(out.toString())
tmp.renameTo(f)
java.nio.file.Files.move(tmp.toPath(), f.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}.onFailure { Log.w(TAG, "could not save the queue", it) }
}
}
@@ -68,7 +68,12 @@ object SavedQueue {
for ((item, root, paused) in back) {
if (DownloadQueue.entry(key(item)) != null) continue
if (paused) DownloadQueue.holdPaused(key(item))
StoresState.install(app, item, root)
if (item.store == Store.GOG && item.extra.containsKey(com.droiddeck.launcher.stores.gog.GogMaintenance.OPERATION)) {
Thread({
runCatching { com.droiddeck.launcher.stores.gog.GogMaintenance.restore(app, item) }
.onFailure { DownloadQueue.recordFailed(app, DownloadEntry(item.store, item.id, item.title), it.message ?: "GOG recovery failed") }
}, "gog-restore").start()
} else StoresState.install(app, item, root)
}
}
}, "store-queue-restore").start()
@@ -33,6 +33,7 @@ object GogAuth {
@Synchronized
private fun saveAccount(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
GogMaintenance.reset()
var username = ""
runCatching {
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = accessToken, userAgent = GALAXY_UA)
@@ -28,6 +28,7 @@ object GogBackend : StoreBackend {
}
override fun signOut(context: Context) {
GogMaintenance.reset()
GogPrefs.get(context).edit().remove("library_cache").remove("library_cache_v2").remove("library_synced_at").apply()
}
@@ -53,7 +54,11 @@ object GogBackend : StoreBackend {
StoresState.post {
StoresState.status.remove(store)
when (result) {
is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store) }
is GogLibrary.SyncResult.Ok -> {
StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) })
StoresState.problems.remove(store)
GogMaintenance.checkInstalled(app, StoresState.installed)
}
is GogLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store)
GogLibrary.SyncResult.Busy -> {}
Loaded 100 of 621 files, more files were not shown because too many files have changed in this diff. Show more