mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-09 17:11:32 -10:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d51395ec7 | ||
|
|
8cf01ebb77 | ||
|
|
7e47db2e2a | ||
|
|
484afffffd | ||
|
|
0b1af34a5f | ||
|
|
2ff682a4f5 | ||
|
|
18f6305977 | ||
|
|
7bc3529722 | ||
|
|
9cc56631cd | ||
|
|
a5b035b247 | ||
|
|
a8cebd024b | ||
|
|
7c49eef79f | ||
|
|
c8c5e22a34 | ||
|
|
fa4a54f64a | ||
|
|
83980035c6 | ||
|
|
8f7cdb7106 | ||
|
|
9a1334b73b | ||
|
|
ab949a4a5a | ||
|
|
b91e8fdeab | ||
|
|
021e7153fa | ||
|
|
524292f457 | ||
|
|
92c1560f63 | ||
|
|
41615d2460 | ||
|
|
bc1a010411 | ||
|
|
d31f234f74 | ||
|
|
6ffe675a57 | ||
|
|
fb5af9d35e | ||
|
|
b75060c280 | ||
|
|
67d391958f | ||
|
|
4770d11e39 | ||
|
|
f69e2d0171 | ||
|
|
b0a8c2065d | ||
|
|
d4fd7ac5c3 | ||
|
|
ed661d7007 | ||
|
|
265335d550 | ||
|
|
2b0d633535 | ||
|
|
3b917c0db3 | ||
|
|
58968c9286 | ||
|
|
5736297987 | ||
|
|
26e2f26ec9 | ||
|
|
23bb361006 | ||
|
|
ee3876b965 | ||
|
|
5d6f9c7d74 | ||
|
|
d5d3aa2ae4 | ||
|
|
31c943c82a | ||
|
|
3b9abf6302 | ||
|
|
161a93a4b3 | ||
|
|
7d3d338fb9 | ||
|
|
a3da6f0bda | ||
|
|
b726e19fa4 | ||
|
|
001ec02a17 | ||
|
|
a69ae77fc5 | ||
|
|
22d39998f0 | ||
|
|
5786e89ff4 | ||
|
|
e892805c2f | ||
|
|
0258f10df0 | ||
|
|
30923635af | ||
|
|
15b00fc23c | ||
|
|
9f7f6d821d | ||
|
|
29c83976d0 | ||
|
|
5de690ef39 | ||
|
|
283e3b4549 | ||
|
|
e9e0186e66 | ||
|
|
8329033121 | ||
|
|
81a6186f9e | ||
|
|
d1bd23df16 | ||
|
|
2410b8c67c | ||
|
|
64950099a3 | ||
|
|
0516d023fa | ||
|
|
110cfc28f8 | ||
|
|
e871557bf2 | ||
|
|
a8b3c2d5dc | ||
|
|
87572a7f91 | ||
|
|
1e92d10fa5 | ||
|
|
92787dd0f0 | ||
|
|
aef1f85aa9 | ||
|
|
198f667bf0 | ||
|
|
52fa41767c | ||
|
|
6469a7693c | ||
|
|
cd1caf523d | ||
|
|
37e0f30cc6 | ||
|
|
23811147aa |
No files matched your search
@@ -60,11 +60,12 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv gcc-aarch64-linux-gnu >/dev/null
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" DESKTOP_KDE_RELEASE_TAG='${{ inputs.tag }}' tools/desktop-kde/build.sh out
|
||||
cp out/kwin/desktop-kde.kwin.txt out/
|
||||
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
|
||||
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
|
||||
echo "pacman repository $(ls out/repo | wc -l) files, $(du -sm out/repo | cut -f1) MiB"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde
|
||||
@@ -77,6 +78,7 @@ jobs:
|
||||
out/desktop-kde.sha256
|
||||
out/desktop-kde.i18n.txt
|
||||
out/desktop-kde-i18n-*.tar.zst
|
||||
out/repo/
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
@@ -89,8 +91,10 @@ jobs:
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
|
||||
prerelease=false
|
||||
case $TAG in *-rc*) prerelease=true ;; esac
|
||||
gh release create "$TAG" --latest=false --prerelease="$prerelease" --title "$TAG" \
|
||||
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
|
||||
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst out/repo/*
|
||||
gh release edit "$TAG" --latest=false --prerelease="$prerelease"
|
||||
@@ -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"
|
||||
@@ -49,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
|
||||
@@ -82,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'
|
||||
@@ -136,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'
|
||||
@@ -287,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
|
||||
@@ -417,6 +433,7 @@ jobs:
|
||||
id: build-label
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BUILD_REF: ${{ github.ref_name }}
|
||||
BUILD_SHA: ${{ github.sha }}
|
||||
@@ -435,6 +452,7 @@ jobs:
|
||||
echo "label=$label" >> "$GITHUB_OUTPUT"
|
||||
echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
|
||||
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
|
||||
echo "DROIDDECK_PR_TITLE=${PR_TITLE//$'\n'/ }" >> "$GITHUB_ENV"
|
||||
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
@@ -530,13 +548,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
|
||||
@@ -626,6 +637,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 "[email protected]"
|
||||
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
|
||||
@@ -637,6 +705,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'
|
||||
@@ -690,7 +762,7 @@ jobs:
|
||||
checks:
|
||||
name: Compile and test APK
|
||||
needs: [build, python-tests]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+21
-3
@@ -78,6 +78,10 @@ def escapedBuildLabel = buildLabel.replace('\\', '\\\\').replace('"', '\\"')
|
||||
def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getOrNull()
|
||||
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
|
||||
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
|
||||
def prTitle = providers.environmentVariable('DROIDDECK_PR_TITLE').getOrNull() ?: ''
|
||||
def buildBranch = providers.environmentVariable('GITHUB_HEAD_REF').getOrNull()?.trim()
|
||||
?: gitValue(['branch', '--show-current']) ?: ''
|
||||
def escapeJava = { String value -> value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ' ').replace('\r', ' ') }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
|
||||
@@ -90,7 +94,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,11 +134,13 @@ 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}"
|
||||
buildConfigField "String", "PR_TITLE", "\"${escapeJava(prTitle)}\""
|
||||
buildConfigField "String", "BUILD_BRANCH", "\"${escapeJava(buildBranch)}\""
|
||||
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
@@ -234,6 +240,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 +293,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 +317,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 {
|
||||
@@ -331,6 +347,8 @@ dependencies {
|
||||
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
// dx (Apache-2.0) turns the Samsung performance SDK a user imports into dex on the phone (SamsungPerf).
|
||||
implementation 'com.jakewharton.android.repackaged:dalvik-dx:16.0.1'
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
|
||||
@@ -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" />
|
||||
@@ -68,6 +69,12 @@
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
|
||||
</service>
|
||||
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
|
||||
android:process=":shortcuts_sync">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s" />
|
||||
<activity android:name=".QuestXrActivity" android:exported="true" android:launchMode="singleTask"
|
||||
android:permission="android.permission.DUMP"
|
||||
@@ -243,6 +250,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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,31 +3,52 @@
|
||||
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
|
||||
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
|
||||
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
|
||||
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
|
||||
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164",
|
||||
"desktop-kde-r3": "8b7142fcec8d86143ae39fc14ffc86907d30d9a5b5a45f49cb1b55109d6fc4eb"
|
||||
},
|
||||
"desktopApplications": {
|
||||
"ark-26.08.1-1-aarch64.pkg.tar.xz": "93e2c2b75ff20cd29b4f312ed1979ef6f27255b93232b93bfcdb31239903c1d4",
|
||||
"breeze-6.7.5-1-aarch64.pkg.tar.xz": "1d7f4eb9d8d234caaea23621909f5233d19462c2f058afeeb54bed7b81bd03bd",
|
||||
"breeze-gtk-6.7.5-1-any.pkg.tar.xz": "a0f59bc4fc426879c8f8a97f6dcc707fb7d1b52913da5eb7e9db83777b6957b7",
|
||||
"breeze-icons-6.30.0-1-aarch64.pkg.tar.xz": "4bae4689032501156687a227024e56554820747644953c67fc8d2e2b6b0b045a",
|
||||
"dolphin-26.08.1-1-aarch64.pkg.tar.xz": "fef738ab8132d3eba1156b98c116699ce0a5efcb4cb462efbacdcf4c47f79a23",
|
||||
"firefox-157.0.1-1-aarch64.pkg.tar.xz": "d142fdb8a339a65eca7cea6a2bba5b29202386166abd9d59851d6757c9eb2163",
|
||||
"gwenview-26.08.1-1-aarch64.pkg.tar.xz": "a9dc7d5ab73fa677905d5a57248b1b9b404b6b16e862958cc7443d387f684a47",
|
||||
"kate-26.08.1-1-aarch64.pkg.tar.xz": "acf50710042746f243a3b219a1ae0f788e338f3dd9d75093e8bb671a618b83b6",
|
||||
"kde-cli-tools-6.7.5-1-aarch64.pkg.tar.xz": "af466398a912a994a97fadfe46aff401d88a90d7fdce7e843dc4218098fa38ac",
|
||||
"kde-gtk-config-6.7.5-1-aarch64.pkg.tar.xz": "ba6cb4cb01dbb3f2c247f2f6973ede3b64c0849f89340217b51dfbf11cb8cb5c",
|
||||
"kinfocenter-6.7.5-1-aarch64.pkg.tar.xz": "f3d5416db96b50f9a5b8a284d82ae21d834a8cce646010abdd7087491c399b0b",
|
||||
"konsole-26.08.1-1-aarch64.pkg.tar.xz": "1ecf87c0d11d620ebe4aeafb02788e0d5f4fe9019ad9c5d9f1011c43237dd5ec",
|
||||
"kscreen-6.7.5-1-aarch64.pkg.tar.xz": "8fbaa6fedf93edeba021a5416b551663654919b27c2ee237d696ec0b0ce7d5b7",
|
||||
"kwin-6.7.5-1-aarch64.pkg.tar.xz": "a7535710c74b90359f80306134b2b7581132cbac508dc84b7e07ef275cc8c3fe",
|
||||
"7zip-26.05-1-aarch64.pkg.tar.xz": "17569bafbc5bd1a044ca1db164b96cb15857a22a3575f36679de46e693ae285e",
|
||||
"ark-26.08.2-1-aarch64.pkg.tar.xz": "b93cc90c4fc8a6cb0247fe8fb0f9de1c2074f5173fa2735d3d1959c9162c08f2",
|
||||
"breeze-6.8.1-1-aarch64.pkg.tar.xz": "70a2ff3afd41602f184814af5c6e298ff84ef7cb7e5880ca7cb4f6fb28521511",
|
||||
"breeze-gtk-6.8.1-1-any.pkg.tar.xz": "e92b9acf3155db22d4a288a43e8d95c20c72b8d8cffd028dd6800eb01a304bf0",
|
||||
"breeze-icons-6.31.0-1-aarch64.pkg.tar.xz": "b252ef02395621b5891d72c5f7fccd1a1ead1ce770886e9fced4bf21e1130096",
|
||||
"discover-6.8.1-1-aarch64.pkg.tar.xz": "64cfca615e7c18530c684325eb36a5c45e18c63b31f651335474f53b0178958c",
|
||||
"dolphin-26.08.2-1-aarch64.pkg.tar.xz": "4b5f7a9212c88d47146b374742f67f5620ab128ad893d945721bb43501066b70",
|
||||
"elisa-26.08.2-1-aarch64.pkg.tar.xz": "fd2df84a2905b59fba0ab1b7fadafe567c5a1dfd44d53ae4cd4996f683b83ba2",
|
||||
"filelight-26.08.2-1-aarch64.pkg.tar.xz": "17f39435104baf0743ab508bdde6e8bd1b3417b025b67ca8f343032d2fdef3e6",
|
||||
"firefox-158.0.1-1-aarch64.pkg.tar.xz": "4940e14c05522bfee8df901e1cfe81f56036c82e8f3d95f9f7a2a12e6b38d232",
|
||||
"flatpak-kcm-6.8.1-1-aarch64.pkg.tar.xz": "3145601002fb87d61307949a3011a8f27a7b1e9851fc59835460f934f8e00098",
|
||||
"git-2.56.0-1-aarch64.pkg.tar.xz": "b152a20edb215e98cada4b2dfbd02a9e925bda0b2de6ac1441e9e60b6698d3b5",
|
||||
"gwenview-26.08.2-1-aarch64.pkg.tar.xz": "02aae3973ec0563022c89e312ce7696d05d33514101ae2a01df3275717929de2",
|
||||
"htop-3.5.3-1-aarch64.pkg.tar.xz": "a58899bd2596d55575493509b3a5aa6f53075cb3d88bb6c712088c57068d9eb7",
|
||||
"kate-26.08.2-1-aarch64.pkg.tar.xz": "89c21ad8e62d20961b13f8fac8d9f4d358b1a3feebed243f6412e4ad996d5e9e",
|
||||
"kcalc-26.08.2-1-aarch64.pkg.tar.xz": "79b3303ccc5ae956e97313439456243e59f0b79a8166ca91606a25d1c9e41029",
|
||||
"kde-cli-tools-6.8.1-1-aarch64.pkg.tar.xz": "b052e9c25e7d6896be67846696f14c9e00430c0445f358ee64cf2a8ea16aa2eb",
|
||||
"kde-gtk-config-6.8.1-1-aarch64.pkg.tar.xz": "3095da034ac5fe8594165e3396f61f083b0c011cd96807146ddfb53690118608",
|
||||
"kfind-26.08.2-1-aarch64.pkg.tar.xz": "b4108cc3bd9efd62970379aadb3cb22e99dd1f54873ff432392edd0a833bb393",
|
||||
"kinfocenter-6.8.1-1-aarch64.pkg.tar.xz": "758882ef13a166205acab0e6bb96ce756d638d91e501db400cb579b9e1331d31",
|
||||
"konsole-26.08.2-1-aarch64.pkg.tar.xz": "0cf27f69d1792a094d4a935f578c82f8151b2d359198d9cd32bfdbd8dec544e4",
|
||||
"kscreen-6.8.1-1-aarch64.pkg.tar.xz": "72c948dfdebfef305623527de27274baf1a2328c87e71033fd852e386d9fdf5c",
|
||||
"kwin-6.8.1-2-aarch64.pkg.tar.xz": "726c98a3f8682b487c2b313633edbd80aa8b20ff657a6b63bbb1b73454f6e0c4",
|
||||
"less-1:710-1-aarch64.pkg.tar.xz": "8e1d4cbecf6d2ae734cfc1c5495367c0a9245f92dfd2bc03d4d0ab14f8b26ff2",
|
||||
"man-db-2.13.1-2-aarch64.pkg.tar.xz": "67515751f848717e1f6d3470f57ad2ea6900ee07618fd2726b6fcaa695687e80",
|
||||
"man-pages-6.19-1-any.pkg.tar.xz": "69df38f1a1aa35240d239ff82a2e067950ac394f23b8a6c7307580ccd7e95f07",
|
||||
"mpv-1:0.41.0-6-aarch64.pkg.tar.xz": "6c89d00736820210a3c7f32af4cf95d155191d463f9df8bd22137c45f30a554a",
|
||||
"plasma-desktop-6.7.5-1-aarch64.pkg.tar.xz": "b266dd42cea965b5ab1cc1ae600877e9d3bc83ec642c61fe1669c4457e641a55",
|
||||
"plasma-integration-6.7.5-1-aarch64.pkg.tar.xz": "daa96da14d9a4d12c0d9c0341e1be6836a9a437c469d61da5e9844d51e2606cf",
|
||||
"plasma-pa-6.7.5-1-aarch64.pkg.tar.xz": "02dbefe9d3e0069b2bb37cac2a6be18b8f0a1887c87704a0716e7a9268eadcd8",
|
||||
"plasma-workspace-6.7.5-1-aarch64.pkg.tar.xz": "3a1455857afaa9ccf342ab6167f0f15794ee9d248b0925398ea6f62f12730a86",
|
||||
"spectacle-1:6.7.5-1-aarch64.pkg.tar.xz": "0c9837412ea6119edef18bcb0a8fceb74634cb35be0ca78b3e1dd1ff2443866e",
|
||||
"systemsettings-6.7.5-1-aarch64.pkg.tar.xz": "b02b6a726857d5125c92d1ec95379216c7a3bc1f187a3b46e88c0d056b29ce70",
|
||||
"xdg-desktop-portal-kde-6.7.5-1-aarch64.pkg.tar.xz": "2c57d17d34d577023eb9cd7b0fca1ba9163549afe60450f6271aa148af099ef2"
|
||||
"okular-26.08.2-1-aarch64.pkg.tar.xz": "aef87cf74730d2345092184d7e9b14a0068ba4030de58564aa770506175cbf06",
|
||||
"plasma-desktop-6.8.1-1-aarch64.pkg.tar.xz": "4b24662a2059b21ca98b5b35b72216f22c9e316057247ea018bf6318b07cfdb6",
|
||||
"plasma-integration-6.8.1-1-aarch64.pkg.tar.xz": "514f7d2a96370814b9374d7c52e1873257af6e18190d130e5a8d3d6d1fffdc54",
|
||||
"plasma-pa-6.8.1-1-aarch64.pkg.tar.xz": "13dff66d30072744c7870bd1a294ae96f0fee086a1533b8052d7dfc038d57c45",
|
||||
"plasma-systemmonitor-6.8.1-1-aarch64.pkg.tar.xz": "551b7c1f24cd5df64de4f5ba199bc71c00e2924c16a8241b21c020584794e10d",
|
||||
"plasma-workspace-6.8.1-1-aarch64.pkg.tar.xz": "4c713990aeab118d50865c818f9689ae7637dc6556e4a0e2fec5e0b348cdcd29",
|
||||
"spectacle-1:6.8.1-1-aarch64.pkg.tar.xz": "0d697803bd0135701688be3869d4d99eec6b8b43ca761610da73f44cb77bfa5c",
|
||||
"systemsettings-6.8.1-1-aarch64.pkg.tar.xz": "db528089fa328d67dadb50be3b822858238785d8c659a9ef95389b993efcb234",
|
||||
"unrar-1:7.3.1-1-aarch64.pkg.tar.xz": "9883e19b1f1adfa430285191b1f96db4cc208608baebd955f3fe9fee92cd3bdd",
|
||||
"unzip-6.0-23-aarch64.pkg.tar.xz": "edc91c7e4e400f20345439744262d1ceded104c78d940659a6b9876076ef33fc",
|
||||
"wget-1.25.0-6-aarch64.pkg.tar.xz": "e2bf0067cca93877c737c7d48bc36fff32e184c7874829429b9473c10a12dd39",
|
||||
"wl-clipboard-1:2.3.0-1-aarch64.pkg.tar.xz": "723a63b140e00085a533ebb1ec36fa9305b5e6cbd8fb80c0521fbeff77a72982",
|
||||
"xclip-0.13-6-aarch64.pkg.tar.xz": "772ec9336b679b0ef277d1ef0b69b74777467239e16ec845af00c07fa849d166",
|
||||
"xdg-desktop-portal-kde-6.8.1-1-aarch64.pkg.tar.xz": "8098c0ef0454335bb7c806049d66977c512657346222606737d72a7ae1de3146",
|
||||
"zip-3.0-14-aarch64.pkg.tar.xz": "9887f177544021809d065d0298514e98f76b55f9120119f748f5a28c8bb51994"
|
||||
},
|
||||
"legacyApplications": {
|
||||
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
desktop-kde-r1
|
||||
desktop-kde-r2
|
||||
desktop-kde-r3
|
||||
@@ -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>)
|
||||
|
||||
@@ -503,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};
|
||||
@@ -596,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) {
|
||||
@@ -2122,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;
|
||||
@@ -2300,7 +2316,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -2375,6 +2393,7 @@ static void on_vsync(int64_t frame_time_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();
|
||||
}
|
||||
|
||||
@@ -2432,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) {
|
||||
@@ -2469,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 = {
|
||||
@@ -2584,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;
|
||||
|
||||
@@ -2648,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) {
|
||||
@@ -2744,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);
|
||||
}
|
||||
@@ -2938,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) {
|
||||
@@ -2948,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);
|
||||
@@ -2963,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,
|
||||
@@ -3104,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);
|
||||
@@ -3203,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);
|
||||
@@ -3220,10 +3304,10 @@ static void send_axis(struct seat_pointer *sp, uint32_t t, uint32_t axis, int va
|
||||
wl_pointer_send_axis(sp->ptr, t, axis, wl_fixed_from_double(value120 / 12.0));
|
||||
}
|
||||
|
||||
static void axis_event(int vertical120, int horizontal120) {
|
||||
static void axis_event(int vertical120, int horizontal120, int finger) {
|
||||
struct surface *target = g_active_constraint ? g_active_constraint->surface
|
||||
: g_desktop ? g_desktop : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y));
|
||||
if (!target || (!vertical120 && !horizontal120)) return;
|
||||
if (!target || (!finger && !vertical120 && !horizontal120)) return;
|
||||
struct wl_client *client = wl_resource_get_client(target->resource);
|
||||
struct seat_pointer *sp;
|
||||
if (!pointer_for(client)) return;
|
||||
@@ -3232,16 +3316,33 @@ static void axis_event(int vertical120, int horizontal120) {
|
||||
uint32_t t = now_ms();
|
||||
pointer_focus(target->resource, wl_fixed_from_double(g_ptr_x - tx), wl_fixed_from_double(g_ptr_y - ty));
|
||||
for_each_pointer_of(client, sp) {
|
||||
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
|
||||
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
|
||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
|
||||
int version = wl_resource_get_version(sp->ptr);
|
||||
if (!finger) {
|
||||
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
|
||||
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
|
||||
} else {
|
||||
if (version >= WL_POINTER_AXIS_SOURCE_SINCE_VERSION)
|
||||
wl_pointer_send_axis_source(sp->ptr, WL_POINTER_AXIS_SOURCE_FINGER);
|
||||
if (!vertical120 && !horizontal120) {
|
||||
if (version >= WL_POINTER_AXIS_STOP_SINCE_VERSION) {
|
||||
wl_pointer_send_axis_stop(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL);
|
||||
wl_pointer_send_axis_stop(sp->ptr, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL);
|
||||
}
|
||||
} else {
|
||||
if (vertical120)
|
||||
wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL, wl_fixed_from_double(vertical120 / 12.0));
|
||||
if (horizontal120)
|
||||
wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, wl_fixed_from_double(horizontal120 / 12.0));
|
||||
}
|
||||
}
|
||||
if (version >= WL_POINTER_FRAME_SINCE_VERSION)
|
||||
wl_pointer_send_frame(sp->ptr);
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
static void scroll_event(int steps) {
|
||||
axis_event(steps * 120, 0);
|
||||
axis_event(steps * 120, 0, 0);
|
||||
}
|
||||
|
||||
static int update_modifiers(uint32_t evdev, int pressed) {
|
||||
@@ -3337,7 +3438,8 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
schedule_render();
|
||||
break;
|
||||
case 10: break;
|
||||
case 11: axis_event(m.p1, m.p2); break;
|
||||
case 11: axis_event(m.p1, m.p2, 0); break;
|
||||
case 12: axis_event(m.p1, m.p2, 1); break;
|
||||
case 20: stream_take(); break;
|
||||
case 21: if (!m.p1) stream_forget_all(); schedule_render(); break;
|
||||
default: deliver_pointer(&m); break;
|
||||
|
||||
@@ -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(×[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);
|
||||
@@ -296,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) {
|
||||
|
||||
@@ -17,6 +17,7 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
com.droiddeck.launcher.ui.Themes.loadGradient(this)
|
||||
com.droiddeck.launcher.ui.BuildWatermark.install(this)
|
||||
// Helper processes must not initialize the main USB provider or guest graphics context.
|
||||
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
|
||||
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
|
||||
@@ -34,8 +35,10 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.init(this)
|
||||
return
|
||||
}
|
||||
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
|
||||
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
|
||||
process == "$packageName:shortcuts_sync") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
@@ -46,6 +49,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)
|
||||
|
||||
@@ -46,6 +46,7 @@ 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.SamsungPerf
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
@@ -109,6 +110,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)
|
||||
@@ -169,6 +172,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var zramClient by mutableStateOf(true)
|
||||
private var vramCapMb by mutableStateOf(0)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var performanceBoost by mutableStateOf(false)
|
||||
private var samsungSdk by mutableStateOf<Boolean?>(null)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var prootFastPath by mutableStateOf(true)
|
||||
private var cursorComposite by mutableStateOf(false)
|
||||
@@ -193,7 +198,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
|
||||
private var storesView by mutableStateOf(SessionPrefs.STORES_VIEWS.first())
|
||||
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
@@ -213,6 +218,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)
|
||||
}
|
||||
@@ -251,6 +257,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private val pickSamsungSdk = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importSamsungSdk(it) }
|
||||
}
|
||||
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
@@ -321,7 +330,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SteamIntegration.Feature.SHORTCUTS -> {
|
||||
if (!integrationState.shortcuts) shortcutsTried = true
|
||||
if (!HomeApp.isDefault(this)) configureIntegration(SteamIntegration.Feature.HOME)
|
||||
else integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
|
||||
else {
|
||||
SteamIntegration.setShortcutsListed(this, true)
|
||||
integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,6 +401,27 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
private fun importSamsungSdk(source: File) {
|
||||
Thread({
|
||||
val message = getString(
|
||||
when (SamsungPerf.import(this, source)) {
|
||||
SamsungPerf.ImportResult.IMPORTED -> R.string.perf_samsung_sdk_imported
|
||||
SamsungPerf.ImportResult.INVALID -> R.string.perf_samsung_sdk_invalid
|
||||
SamsungPerf.ImportResult.FAILED -> R.string.perf_samsung_sdk_failed
|
||||
},
|
||||
)
|
||||
val imported = SamsungPerf.imported(this)
|
||||
ui.post {
|
||||
samsungSdk = imported
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "samsung-sdk-import").start()
|
||||
}
|
||||
|
||||
private fun pickSamsungSdk() {
|
||||
pickSamsungSdk.launch(InAppFilePicker.buildIntent(this, listOf("zip", "jar"), getString(R.string.perf_samsung_sdk_pick)))
|
||||
}
|
||||
|
||||
private fun importSaves(name: String, game: () -> GameSaves.Game) {
|
||||
if (SessionState.running) {
|
||||
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -472,6 +505,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 ->
|
||||
@@ -514,12 +557,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()
|
||||
@@ -649,7 +703,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 ->
|
||||
@@ -690,16 +760,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()
|
||||
@@ -902,15 +981,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
|
||||
@@ -965,6 +1050,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() } }
|
||||
@@ -999,6 +1091,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,
|
||||
@@ -1227,6 +1324,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
|
||||
@@ -1315,7 +1424,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)
|
||||
@@ -1457,6 +1566,9 @@ class FrontEndActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (intent.action == SteamIntegration.ACTION_SETUP) {
|
||||
gameLinkReading = false
|
||||
pendingGameLink = null
|
||||
syncDualPanel()
|
||||
readIntegrationIntent(intent)
|
||||
return
|
||||
}
|
||||
@@ -1550,15 +1662,18 @@ 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
|
||||
SteamIntegration.syncShortcutsListing(this)
|
||||
refreshWifiDiscovery()
|
||||
com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
@@ -1573,14 +1688,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()
|
||||
@@ -1603,6 +1725,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)
|
||||
@@ -1617,6 +1741,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 {
|
||||
@@ -1644,6 +1769,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
|
||||
@@ -1677,13 +1805,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)
|
||||
@@ -2103,6 +2249,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
fsyncFirst = fsyncFirst,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
performanceBoost = performanceBoost, samsungSdk = samsungSdk,
|
||||
ramCapMb = ramCapMb, vramCapMb = vramCapMb, zramClient = zramClient,
|
||||
deviceRamMb = (android.app.ActivityManager.MemoryInfo()
|
||||
.also((getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)::getMemoryInfo).totalMem / (1024 * 1024)).toInt(),
|
||||
@@ -2120,6 +2267,12 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
|
||||
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
|
||||
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
|
||||
onPerformanceBoost = { on ->
|
||||
SessionPrefs.setPerformanceBoost(this, on)
|
||||
performanceBoost = on
|
||||
if (on && samsungSdk == false) pickSamsungSdk()
|
||||
},
|
||||
onImportSamsungSdk = { pickSamsungSdk() },
|
||||
onRamCapMb = { mb -> SessionPrefs.setRamCapMb(this, mb); ramCapMb = mb },
|
||||
onZramClient = { on -> SessionPrefs.setZramClient(this, on); zramClient = on },
|
||||
onVramCapMb = { mb -> SessionPrefs.setVramCapMb(this, mb); vramCapMb = mb },
|
||||
@@ -2198,6 +2351,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun refreshAddedGames() {
|
||||
checkUsbLink()
|
||||
sgdbKeyKnown()
|
||||
fetchAddedGameArt()
|
||||
}
|
||||
@@ -2302,6 +2456,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() {
|
||||
@@ -2321,6 +2489,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
zramClient = SessionPrefs.zramClient(this)
|
||||
vramCapMb = SessionPrefs.vramCapMb(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
performanceBoost = SessionPrefs.performanceBoost(this)
|
||||
samsungSdk = if (SamsungPerf.offered(this)) SamsungPerf.imported(this) else null
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
prootFastPath = SessionPrefs.prootFastPath(this)
|
||||
cursorComposite = SessionPrefs.cursorComposite(this)
|
||||
@@ -2350,6 +2520,7 @@ 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)
|
||||
@@ -2392,15 +2563,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 }
|
||||
@@ -2494,7 +2682,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() {
|
||||
@@ -2554,15 +2742,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.droiddeck.launcher.audio
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import android.util.Log
|
||||
|
||||
object DirectAudioTiming {
|
||||
private const val TAG = "DirectAudioTiming"
|
||||
const val PERIOD_ENV = "AUDIO_DIRECT_PERIOD_MS"
|
||||
const val BUFFER_ENV = "AUDIO_DIRECT_MS"
|
||||
const val SLOW_OUTPUT_PERIOD_MS = 20
|
||||
const val SLOW_OUTPUT_BUFFER_MS = 60
|
||||
|
||||
fun environment(lowLatencyOutput: Boolean): List<String> =
|
||||
if (lowLatencyOutput) emptyList()
|
||||
else listOf("$PERIOD_ENV=$SLOW_OUTPUT_PERIOD_MS", "$BUFFER_ENV=$SLOW_OUTPUT_BUFFER_MS")
|
||||
|
||||
fun lowLatencyOutput(): Boolean = try {
|
||||
val track = AudioTrack.Builder()
|
||||
.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_GAME)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build())
|
||||
.setAudioFormat(AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
|
||||
.setSampleRate(48000)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
|
||||
.build())
|
||||
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
try {
|
||||
track.performanceMode == AudioTrack.PERFORMANCE_MODE_LOW_LATENCY
|
||||
} finally {
|
||||
track.release()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "audio: could not check for low-latency output; DirectAudio keeps its defaults", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ object GameEnvironmentOptions {
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("AUDIO_DIRECT_MS", "", 0, Type.NUMBER),
|
||||
Option("AUDIO_DIRECT_PERIOD_MS", "", 0, Type.NUMBER),
|
||||
Option("DROIDDECK_GAME_CORES", "", R.string.game_env_cores, Type.TEXT),
|
||||
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class ShortcutsListingReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) SteamIntegration.syncShortcutsListing(context)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
@@ -55,6 +56,33 @@ object SteamIntegration {
|
||||
return mode == android.app.AppOpsManager.MODE_ERRORED || mode == android.app.AppOpsManager.MODE_IGNORED
|
||||
}
|
||||
|
||||
private fun shortcutsComponent(context: Context) = ComponentName(context, HomeButtonService::class.java)
|
||||
|
||||
fun shortcutsChosen(context: Context): Boolean = shortcutsSelection(context) == true
|
||||
|
||||
private fun shortcutsSelection(context: Context): Boolean? {
|
||||
val target = shortcutsComponent(context)
|
||||
val enabled = runCatching {
|
||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
|
||||
}.getOrElse { return null }.orEmpty()
|
||||
return enabled.split(':').any { ComponentName.unflattenFromString(it.trim()) == target }
|
||||
}
|
||||
|
||||
fun setShortcutsListed(context: Context, listed: Boolean) {
|
||||
val packages = context.packageManager
|
||||
val component = shortcutsComponent(context)
|
||||
val state = if (listed) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
runCatching {
|
||||
if (packages.getComponentEnabledSetting(component) != state) {
|
||||
packages.setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncShortcutsListing(context: Context) {
|
||||
shortcutsSelection(context)?.let { setShortcutsListed(context, it) }
|
||||
}
|
||||
|
||||
fun dismissSuggestion(context: Context) {
|
||||
SessionPrefs.sessionPrefs(context).edit().putBoolean("steam_integration_seen", true).apply()
|
||||
}
|
||||
|
||||
@@ -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(".") && !isSteamLibraryFolder(it.name) }.sortedBy { it.name.lowercase() }
|
||||
if (dirs.any { it.name.lowercase() in GAME_PARTS || GameExePicker.auxiliaryDir(it) }) return null
|
||||
return dirs.filter { rank(it).exes.isNotEmpty() }.takeIf { it.size >= 2 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -105,10 +111,7 @@ object AutoComponents {
|
||||
|
||||
@VisibleForTesting internal var gameRunning: () -> Boolean = { gameProcessRunning() }
|
||||
|
||||
private fun gameProcessRunning(): Boolean = File("/proc").listFiles()?.any { p ->
|
||||
p.name.all(Char::isDigit) && runCatching { String(File(p, "cmdline").readBytes()).replace('\u0000', ' ') }.getOrNull()
|
||||
?.let { it.contains("SteamLaunch AppId=") || it.contains("proton waitforexitandrun") } == true
|
||||
} == true
|
||||
private fun gameProcessRunning(): Boolean = com.droiddeck.launcher.session.SteamGameProcess.running()
|
||||
|
||||
fun queueInSession(context: Context, game: Library.SteamGame) {
|
||||
if (appKey(game) == null) return
|
||||
@@ -185,18 +188,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 +239,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 +331,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>) {
|
||||
|
||||
@@ -89,6 +89,8 @@ object GameExePicker {
|
||||
return score
|
||||
}
|
||||
|
||||
internal fun auxiliaryDir(dir: File): Boolean = skipDir(dir, dir.name)
|
||||
|
||||
private fun skipDir(dir: File, relative: String): Boolean {
|
||||
val name = dir.name.lowercase().trim()
|
||||
if (name in SKIP_DIRS || SKIP_DIR_PREFIX.any { name.startsWith(it) }) return true
|
||||
|
||||
@@ -24,15 +24,33 @@ object GamesFolderMigration {
|
||||
for (root in roots) {
|
||||
var n = 0
|
||||
val steamInstalls = AddedGames.steamInstallDirs(root.host)
|
||||
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
|
||||
if (AddedGames.isSteamLibraryFolder(folder.name) || folder.name.lowercase() in steamInstalls) continue
|
||||
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
|
||||
if (folder.path in entries) continue
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
|
||||
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
|
||||
entries.add(folder.path)
|
||||
if (folder.path !in removed) n++
|
||||
for (top in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
|
||||
if (AddedGames.isSteamLibraryFolder(top.name) || top.name.lowercase() in steamInstalls) continue
|
||||
if (top.absolutePath in storeRoots || StoreGameSidecar.read(top) != null) continue
|
||||
if (top.path in entries) continue
|
||||
val topPick = SessionPrefs.addedGameExe(context, top.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val groups = AddedGames.groupGames(top)
|
||||
val played = topPick ?: groups?.let { AddedGames.rank(top).exes.firstOrNull() }
|
||||
val owner = played?.let { exe -> top.listFiles { f -> f.isDirectory }?.firstOrNull { exe.path.startsWith(it.path + File.separator) } }
|
||||
val folders = when {
|
||||
groups == null -> listOf(top)
|
||||
topPick == null || owner in groups -> groups
|
||||
owner == null || owner.name.startsWith(".") || AddedGames.isSteamLibraryFolder(owner.name) -> listOf(top)
|
||||
else -> (groups + owner).sortedBy { it.name.lowercase() }
|
||||
}
|
||||
for (folder in folders) {
|
||||
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
|
||||
if (folder.path in entries) continue
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
?: topPick?.takeIf { it.path.startsWith(folder.path + File.separator) }
|
||||
val ranked = AddedGames.rank(folder).exes.firstOrNull()
|
||||
val exe = picked ?: played?.takeIf { folder == owner } ?: ranked ?: continue
|
||||
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null || exe != ranked))
|
||||
entries.add(folder.path)
|
||||
if (folder != top && folder == owner) SessionPrefs.inheritAddedGame(context, top.path, folder.path)
|
||||
if (folder != top && top.path in removed) SessionPrefs.setAddedGameRemoved(context, folder.path, true)
|
||||
else if (folder.path !in removed) n++
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "games: migrated ${root.host} → $n games")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ class HomeButtonService : AccessibilityService(), InputManager.InputDeviceListen
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
HomeButtonShortcuts.release()
|
||||
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,13 +16,19 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val hostFile = File(context.filesDir, "session/android-clipboard")
|
||||
private var active = false
|
||||
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh() }
|
||||
private var published = false
|
||||
private var shared: String? = null
|
||||
private var echo: String? = null
|
||||
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
|
||||
private val guestListener = object : WaylandCompositor.ClipboardListener {
|
||||
override fun onGuestClipboardText(text: String) {
|
||||
handler.post {
|
||||
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
|
||||
shared = text
|
||||
published = true
|
||||
if (current?.toString() != text) {
|
||||
echo = text
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("DroidDeck", text))
|
||||
}
|
||||
}
|
||||
@@ -46,17 +52,23 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
fun refresh(changed: Boolean = false) {
|
||||
if (!active || !hasFocus()) return
|
||||
val clip = clipboard.primaryClip
|
||||
if (clip == null) {
|
||||
publish(null)
|
||||
} else if (clip.itemCount > 0) {
|
||||
publish(clip.getItemAt(0).text?.toString())
|
||||
if (clip != null && clip.itemCount == 0) return
|
||||
val text = clip?.getItemAt(0)?.text?.toString()
|
||||
if (echo != null && text == echo) {
|
||||
echo = null
|
||||
return
|
||||
}
|
||||
echo = null
|
||||
if (!changed && published && text == shared) return
|
||||
publish(text)
|
||||
}
|
||||
|
||||
private fun publish(text: String?) {
|
||||
published = true
|
||||
shared = text
|
||||
WaylandCompositor.setClipboardText(text)
|
||||
val pending = File(hostFile.parentFile, "android-clipboard.pending")
|
||||
try {
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
|
||||
class TwoFingerGestures(private val slopPx: Float, private val listener: Listener) {
|
||||
interface Listener {
|
||||
fun cancelTouch()
|
||||
fun replay(event: MotionEvent)
|
||||
fun rightClick(x: Float, y: Float)
|
||||
fun scrollStart(x: Float, y: Float)
|
||||
fun scroll(dx: Float, dy: Float)
|
||||
fun scrollEnd()
|
||||
}
|
||||
|
||||
private enum class State { IDLE, PENDING, CANDIDATE, TAPPED, HELD, SCROLLING, PASSING }
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var state = State.IDLE
|
||||
private var x = 0f
|
||||
private var y = 0f
|
||||
private var twoAt = 0L
|
||||
private var startSpan = 0f
|
||||
private var lastX = 0f
|
||||
private var lastY = 0f
|
||||
private val starts = HashMap<Int, Pair<Float, Float>>()
|
||||
private val held = ArrayList<MotionEvent>()
|
||||
private val hold = Runnable {
|
||||
if (state != State.CANDIDATE) return@Runnable
|
||||
state = State.HELD
|
||||
drop()
|
||||
listener.cancelTouch()
|
||||
listener.rightClick(x, y)
|
||||
}
|
||||
private val release = Runnable {
|
||||
if (state == State.PENDING) pass(State.IDLE)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (state == State.SCROLLING) listener.scrollEnd()
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(release)
|
||||
drop()
|
||||
state = State.IDLE
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
cancel()
|
||||
starts.clear()
|
||||
x = event.x
|
||||
y = event.y
|
||||
note(event, event.actionIndex)
|
||||
state = State.PENDING
|
||||
keep(event)
|
||||
handler.postDelayed(release, LAND_MS)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
note(event, event.actionIndex)
|
||||
when (state) {
|
||||
State.PENDING -> {
|
||||
handler.removeCallbacks(release)
|
||||
keep(event)
|
||||
if (event.pointerCount != 2) {
|
||||
pass(State.PASSING)
|
||||
return true
|
||||
}
|
||||
state = State.CANDIDATE
|
||||
twoAt = event.eventTime
|
||||
startSpan = span(event)
|
||||
lastX = midX(event)
|
||||
lastY = midY(event)
|
||||
handler.postDelayed(hold, HOLD_MS)
|
||||
return true
|
||||
}
|
||||
State.CANDIDATE -> {
|
||||
keep(event)
|
||||
pass(State.PASSING)
|
||||
return true
|
||||
}
|
||||
State.SCROLLING, State.HELD, State.TAPPED -> return true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> when (state) {
|
||||
State.PENDING -> {
|
||||
keep(event)
|
||||
if (moved(event)) pass(State.IDLE)
|
||||
return true
|
||||
}
|
||||
State.CANDIDATE -> {
|
||||
if (event.pointerCount == 2 && moved(event)) {
|
||||
handler.removeCallbacks(hold)
|
||||
if (abs(span(event) - startSpan) > slopPx * 2) {
|
||||
keep(event)
|
||||
pass(State.PASSING)
|
||||
return true
|
||||
}
|
||||
state = State.SCROLLING
|
||||
drop()
|
||||
listener.cancelTouch()
|
||||
lastX = midX(event)
|
||||
lastY = midY(event)
|
||||
listener.scrollStart(lastX, lastY)
|
||||
return true
|
||||
}
|
||||
keep(event)
|
||||
return true
|
||||
}
|
||||
State.SCROLLING -> {
|
||||
if (event.pointerCount >= 2) {
|
||||
val mx = midX(event)
|
||||
val my = midY(event)
|
||||
listener.scroll(lastX - mx, lastY - my)
|
||||
lastX = mx
|
||||
lastY = my
|
||||
}
|
||||
return true
|
||||
}
|
||||
State.TAPPED, State.HELD -> return true
|
||||
else -> Unit
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_UP -> when (state) {
|
||||
State.CANDIDATE -> {
|
||||
handler.removeCallbacks(hold)
|
||||
keep(event)
|
||||
if (event.eventTime - twoAt < TAP_MS) {
|
||||
state = State.TAPPED
|
||||
drop()
|
||||
return true
|
||||
}
|
||||
pass(State.PASSING)
|
||||
return true
|
||||
}
|
||||
State.SCROLLING, State.HELD, State.TAPPED -> return true
|
||||
else -> Unit
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val was = state
|
||||
if (was == State.PENDING) {
|
||||
keep(event)
|
||||
pass(State.IDLE)
|
||||
return true
|
||||
}
|
||||
cancel()
|
||||
when (was) {
|
||||
State.TAPPED -> {
|
||||
listener.cancelTouch()
|
||||
listener.rightClick(x, y)
|
||||
return true
|
||||
}
|
||||
State.SCROLLING, State.HELD -> return true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> cancel()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun keep(event: MotionEvent) {
|
||||
held += MotionEvent.obtain(event)
|
||||
}
|
||||
|
||||
private fun drop() {
|
||||
held.forEach { it.recycle() }
|
||||
held.clear()
|
||||
}
|
||||
|
||||
private fun pass(next: State) {
|
||||
handler.removeCallbacks(release)
|
||||
handler.removeCallbacks(hold)
|
||||
state = next
|
||||
val events = held.toList()
|
||||
held.clear()
|
||||
events.forEach {
|
||||
listener.replay(it)
|
||||
it.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun note(event: MotionEvent, index: Int) {
|
||||
starts[event.getPointerId(index)] = event.getX(index) to event.getY(index)
|
||||
}
|
||||
|
||||
private fun moved(event: MotionEvent): Boolean = (0 until event.pointerCount).any { i ->
|
||||
val start = starts[event.getPointerId(i)] ?: return@any false
|
||||
hypot(event.getX(i) - start.first, event.getY(i) - start.second) > slopPx
|
||||
}
|
||||
|
||||
private fun span(event: MotionEvent) = hypot(event.getX(1) - event.getX(0), event.getY(1) - event.getY(0))
|
||||
private fun midX(event: MotionEvent) = (event.getX(0) + event.getX(1)) / 2f
|
||||
private fun midY(event: MotionEvent) = (event.getY(0) + event.getY(1)) / 2f
|
||||
|
||||
companion object {
|
||||
const val LAND_MS = 250L
|
||||
const val TAP_MS = 350L
|
||||
const val HOLD_MS = 500L
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -115,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)
|
||||
@@ -169,6 +177,7 @@ object DesktopCatalog {
|
||||
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)
|
||||
@@ -217,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" }
|
||||
}
|
||||
@@ -239,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 ->
|
||||
|
||||
@@ -73,10 +73,42 @@ internal object DesktopFileRemoval {
|
||||
}
|
||||
}
|
||||
|
||||
private val RECORD = Regex("[A-Za-z0-9@._+:-]+")
|
||||
|
||||
fun desktopRecords(root: File): Set<String> {
|
||||
val list = safeFile(root, "usr/share/droiddeck/pacman/desktop-packages.txt") ?: return emptySet()
|
||||
if (!Files.isRegularFile(list.toPath(), NOFOLLOW_LINKS)) return emptySet()
|
||||
return list.readLines().mapNotNull { line ->
|
||||
line.split(' ').takeIf { it.size == 2 }?.let { "${it[0]}-${it[1]}" }?.takeIf { RECORD.matches(it) && it != "." && it != ".." }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun removeRecords(root: File, records: Set<String>) {
|
||||
val lib = File(root, "var/lib/pacman")
|
||||
if (Files.isSymbolicLink(File(root, "var").toPath()) || Files.isSymbolicLink(lib.toPath())) return
|
||||
val local = File(lib, "local")
|
||||
if (!local.isDirectory || Files.isSymbolicLink(local.toPath())) return
|
||||
for (record in records) {
|
||||
if (!RECORD.matches(record) || record == "." || record == "..") continue
|
||||
val dir = File(local, record)
|
||||
if (Files.isSymbolicLink(dir.toPath()) || !dir.isDirectory) continue
|
||||
for (file in dir.listFiles().orEmpty()) {
|
||||
if (Files.isSymbolicLink(file.toPath()) || file.isFile) Files.delete(file.toPath())
|
||||
}
|
||||
if (!dir.delete()) throw IOException("Cannot remove $dir")
|
||||
}
|
||||
for (name in listOf("sync/droiddeck.db", "droiddeck-desktop")) {
|
||||
val file = File(lib, name)
|
||||
if (Files.isSymbolicLink(file.toPath()) || file.isFile) Files.delete(file.toPath())
|
||||
}
|
||||
}
|
||||
|
||||
fun runtimeFiles(root: File): Set<String> = buildSet {
|
||||
val database = File(root, "var/lib/pacman/local")
|
||||
if (!database.isDirectory || Files.isSymbolicLink(database.toPath())) throw IOException("Runtime package database missing")
|
||||
val desktop = desktopRecords(root)
|
||||
for (pkg in database.listFiles().orEmpty()) {
|
||||
if (pkg.name in desktop) continue
|
||||
val files = File(pkg, "files")
|
||||
if (!pkg.isDirectory || Files.isSymbolicLink(pkg.toPath()) || !files.isFile || Files.isSymbolicLink(files.toPath())) continue
|
||||
var inFiles = false
|
||||
|
||||
@@ -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 }
|
||||
@@ -50,12 +57,14 @@ internal object DesktopRemoval {
|
||||
check(version in versions && (installing == null || installing.version in versions)) { context.getString(R.string.desktop_removal_unknown) }
|
||||
val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse)
|
||||
val keep = protected(context, root)
|
||||
val records = DesktopFileRemoval.desktopRecords(root)
|
||||
cleanLegacy(context, listener)
|
||||
FileUtils.writeString(pending, version!!)
|
||||
check(FileUtils.readString(pending)?.trim() == version) { "Could not reserve desktop removal" }
|
||||
if (marker.exists() && !marker.delete()) error("Could not clear desktop marker")
|
||||
val removing = context.getString(R.string.desktop_removing)
|
||||
val result = DesktopFileRemoval.remove(root, entries, keep) { listener.onProgress(removing, it) }
|
||||
DesktopFileRemoval.removeRecords(root, records)
|
||||
for ((asset, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
|
||||
val target = DesktopFileRemoval.safeFile(root, path) ?: continue
|
||||
if (!isRegularFile(target)) continue
|
||||
@@ -74,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)
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,9 @@ import kotlin.math.abs
|
||||
* under 90 fps against 2-7% unboosted. A game with no frames (Factorio's benchmark, a loading screen)
|
||||
* or one falling behind the panel is boosted; one holding pace keeps what it has for [PACE_LEAVE_S]
|
||||
* seconds and is then let go.
|
||||
*
|
||||
* Part of the Performance boost switch, on by default only where it was measured ([measuredHere]).
|
||||
* Another phone's HAL may boost differently, or not at all.
|
||||
*/
|
||||
object GameThreadHints {
|
||||
private const val TAG = "GameThreadHints"
|
||||
@@ -46,21 +49,23 @@ object GameThreadHints {
|
||||
private const val LEAVE = 0.15 // a capped game's main thread idles to ~30% on a prime core; under 0.3 it hopped back and forth
|
||||
private const val MAX_THREADS = 2 // one per prime core; a third would be sped up in place instead
|
||||
private const val TOP_PROCESSES = 3
|
||||
private const val PACE_WINDOW_S = 3
|
||||
private const val PACE_LEAVE_S = 10
|
||||
internal const val PACE_WINDOW_S = 3
|
||||
internal const val PACE_LEAVE_S = 10
|
||||
private const val PACE_TOLERANCE = 0.08
|
||||
|
||||
/** Busy, but not the game: the app itself and the Steam client, whose UI would otherwise hold the prime cores. */
|
||||
private val NOT_GAMES = setOf("steam", "steamwebhelper")
|
||||
|
||||
/** The Odin 3 (CQ8725S), where the boost was measured. */
|
||||
val measuredHere: Boolean get() = Build.VERSION.SDK_INT >= 31 && Build.SOC_MODEL == "CQ8725S"
|
||||
|
||||
private var worker: Thread? = null
|
||||
|
||||
/** At a session's start. The line is for the log. */
|
||||
@Synchronized
|
||||
fun start(context: Context): String {
|
||||
if (!SessionPrefs.gameThreadHints(context)) return "game thread hints: off"
|
||||
// Measured on the Odin 3 (CQ8725S) only; another HAL may boost differently, or not at all.
|
||||
if (Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S") return "game thread hints: Odin 3 only"
|
||||
if (!SessionPrefs.performanceBoost(context)) return "game thread hints: off"
|
||||
if (Build.VERSION.SDK_INT < 31) return "game thread hints: needs Android 12"
|
||||
val hm = context.getSystemService(PerformanceHintManager::class.java)
|
||||
?: return "game thread hints: no PerformanceHintManager"
|
||||
val pm = context.getSystemService(PowerManager::class.java)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import com.android.dx.command.dexer.DxContext
|
||||
import com.android.dx.command.dexer.Main
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import dalvik.system.DexClassLoader
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Samsung's Galaxy Performance SDK (com.samsung.sdk.sperf): CPU, GPU and memory-bus clock floors from
|
||||
* Samsung's own service. Its licence does not allow passing the jar on, so a Samsung owner downloads
|
||||
* it from [DOWNLOAD_URL] and imports it; it is turned into dex on the phone and loaded from there.
|
||||
*
|
||||
* The presets are renewed every [RENEW_S] seconds with a [HOLD_MS] timeout, so Samsung's service
|
||||
* lets go on its own if the app dies.
|
||||
*/
|
||||
object SamsungPerf {
|
||||
enum class ImportResult { IMPORTED, INVALID, FAILED }
|
||||
|
||||
private const val TAG = "SamsungPerf"
|
||||
const val DOWNLOAD_URL = "https://developer.samsung.com/galaxy-performance/download.html"
|
||||
private const val SPERF = "com.samsung.sdk.sperf"
|
||||
private val REQUIRED = listOf("SPerf", "PerformanceManager").map { "${SPERF.replace('.', '/')}/$it.class" }
|
||||
private const val PRESETS = 64 or 128 or 256 // PerformanceManager.PRESET_TYPE_CPU | _GPU | _BUS
|
||||
private const val HOLD_MS = 5000
|
||||
private const val RENEW_S = 2
|
||||
|
||||
val samsung: Boolean get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
|
||||
fun offered(context: Context) = samsung || SessionPrefs.samsungPerfAnyPhone(context)
|
||||
|
||||
fun imported(context: Context) = dexFile(context).isFile
|
||||
|
||||
private fun dir(context: Context) = File(context.filesDir, "samsung-perf")
|
||||
|
||||
private fun dexFile(context: Context) = File(dir(context), "perfsdk.dex.jar")
|
||||
|
||||
@Synchronized
|
||||
fun import(context: Context, source: File): ImportResult {
|
||||
val dir = dir(context).apply { mkdirs() }
|
||||
val jar = File(dir, "perfsdk.jar.staged")
|
||||
val dex = File(dir, "perfsdk.dex.jar.staged")
|
||||
try {
|
||||
if (!extractJar(source, jar)) return ImportResult.INVALID
|
||||
val log = ByteArrayOutputStream()
|
||||
val dx = DxContext(log, log)
|
||||
val args = Main.Arguments(dx).apply {
|
||||
fileNames = arrayOf(jar.path)
|
||||
outName = dex.path
|
||||
jarOutput = true
|
||||
minSdkVersion = 26
|
||||
makeOptionsObjects()
|
||||
}
|
||||
val status = Main(dx).runDx(args)
|
||||
if (status != 0 || !dex.isFile) {
|
||||
Log.w(TAG, "dx exit $status: $log")
|
||||
return ImportResult.FAILED
|
||||
}
|
||||
val target = dexFile(context)
|
||||
target.setWritable(true)
|
||||
target.delete()
|
||||
if (!dex.renameTo(target)) return ImportResult.FAILED
|
||||
target.setReadOnly()
|
||||
sdk = null
|
||||
unusable = false
|
||||
Log.i(TAG, "SDK from $source ready (${target.length()} bytes of dex)")
|
||||
return ImportResult.IMPORTED
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "could not import $source", t)
|
||||
return ImportResult.FAILED
|
||||
} finally {
|
||||
jar.delete()
|
||||
dex.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/** Samsung's zip also holds the sample app's different perfsdk.jar; Libs/ is the SDK's own. */
|
||||
internal fun extractJar(source: File, target: File): Boolean = try {
|
||||
ZipFile(source).use { zip -> extractJar(zip, source, target) }
|
||||
} catch (_: java.util.zip.ZipException) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun extractJar(zip: ZipFile, source: File, target: File): Boolean {
|
||||
if (REQUIRED.all { zip.getEntry(it) != null }) {
|
||||
source.copyTo(target, overwrite = true)
|
||||
return true
|
||||
}
|
||||
val jars = zip.entries().asSequence()
|
||||
.filter { !it.isDirectory && it.name.endsWith(".jar", ignoreCase = true) }
|
||||
.sortedByDescending { it.name.contains("Libs/") }
|
||||
.toList()
|
||||
for (entry in jars) {
|
||||
zip.getInputStream(entry).use { input -> target.outputStream().use { input.copyTo(it) } }
|
||||
val found = runCatching { ZipFile(target).use { inner -> REQUIRED.all { inner.getEntry(it) != null } } }.getOrDefault(false)
|
||||
if (found) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private class Sdk(val manager: Any, val start: Method, val stop: Method)
|
||||
|
||||
private var sdk: Sdk? = null
|
||||
private var unusable = false
|
||||
|
||||
/** SPerf.initialize is false where Samsung's service is missing. */
|
||||
@Synchronized
|
||||
private fun load(context: Context): Sdk? {
|
||||
sdk?.let { return it }
|
||||
if (unusable || !imported(context)) return null
|
||||
try {
|
||||
val loader = DexClassLoader(dexFile(context).path, context.codeCacheDir.path, null, context.classLoader)
|
||||
val sperf = loader.loadClass("$SPERF.SPerf")
|
||||
runCatching { sperf.getMethod("setDebugModeEnabled", Boolean::class.javaPrimitiveType).invoke(null, false) }
|
||||
if (sperf.getMethod("initialize", Context::class.java).invoke(null, context.applicationContext) != true) {
|
||||
Log.i(TAG, "SDK loaded, but this phone does not support it")
|
||||
unusable = true
|
||||
return null
|
||||
}
|
||||
val pm = loader.loadClass("$SPERF.PerformanceManager")
|
||||
val manager = pm.getMethod("getInstance").invoke(null) ?: return null
|
||||
val int = Int::class.javaPrimitiveType
|
||||
return Sdk(manager, pm.getMethod("start", int, int), pm.getMethod("stop")).also { sdk = it }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "SDK not usable", t)
|
||||
unusable = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private var worker: Thread? = null
|
||||
|
||||
/** At a session's start. The line is for the log. */
|
||||
@Synchronized
|
||||
fun start(context: Context): String {
|
||||
if (!offered(context)) return "samsung boost: not a Samsung phone"
|
||||
if (!SessionPrefs.performanceBoost(context)) return "samsung boost: off"
|
||||
if (!imported(context)) return "samsung boost: SDK not imported"
|
||||
val app = context.applicationContext
|
||||
worker?.interrupt()
|
||||
worker = Thread({ run(app) }, "samsung-boost").apply { isDaemon = true; start() }
|
||||
return "samsung boost: watching"
|
||||
}
|
||||
|
||||
/** At a session's end. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
worker?.interrupt()
|
||||
worker = null
|
||||
}
|
||||
|
||||
private fun run(context: Context) {
|
||||
val sdk = load(context) ?: return
|
||||
val pm = context.getSystemService(PowerManager::class.java)
|
||||
val frames = ArrayDeque<Long>()
|
||||
var pacedFor = 0
|
||||
var boosting = false
|
||||
var tick = 0
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted) {
|
||||
frames.addLast(WaylandCompositor.gameFrameCount())
|
||||
if (frames.size > GameThreadHints.PACE_WINDOW_S + 1) frames.removeFirst()
|
||||
val fps = (frames.last() - frames.first()).toDouble() / (frames.size - 1).coerceAtLeast(1)
|
||||
val paced = frames.size > GameThreadHints.PACE_WINDOW_S && GameThreadHints.holdsPace(fps, SessionState.refreshHz.toDouble())
|
||||
pacedFor = if (paced) pacedFor + 1 else 0
|
||||
val hot = Build.VERSION.SDK_INT >= 29 && pm.currentThermalStatus >= PowerManager.THERMAL_STATUS_SEVERE
|
||||
val want = fps >= 1 && !pm.isPowerSaveMode && !hot && pacedFor < GameThreadHints.PACE_LEAVE_S
|
||||
if (want && (!boosting || tick % RENEW_S == 0)) {
|
||||
val result = sdk.start.invoke(sdk.manager, PRESETS, HOLD_MS)
|
||||
if (!boosting) Log.i(TAG, "boosting (game ${fps.toInt()} fps, start $result)")
|
||||
} else if (!want && boosting) {
|
||||
sdk.stop.invoke(sdk.manager)
|
||||
Log.i(TAG, "let go (game ${fps.toInt()} fps${if (hot) ", hot" else ""}${if (paced) ", holding pace" else ""})")
|
||||
}
|
||||
boosting = want
|
||||
tick++
|
||||
Thread.sleep(1000)
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "stopped", t)
|
||||
} finally {
|
||||
if (boosting) runCatching { sdk.stop.invoke(sdk.manager) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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) {
|
||||
@@ -119,10 +147,34 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
fun storesView(context: Context): String =
|
||||
prefs(context).getString("storesView", null)?.takeIf { it in STORES_VIEWS } ?: STORES_VIEWS.first()
|
||||
prefs(context).getString("storesView", null)?.takeIf { it in STORES_VIEWS } ?: DEFAULT_STORES_VIEW
|
||||
|
||||
fun setStoresView(context: Context, view: String) {
|
||||
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
|
||||
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else DEFAULT_STORES_VIEW).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)
|
||||
@@ -147,6 +199,7 @@ object SessionPrefs {
|
||||
|
||||
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
|
||||
val STORES_VIEWS = listOf("cards", "capsules", "list")
|
||||
const val DEFAULT_STORES_VIEW = "capsules"
|
||||
|
||||
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
|
||||
|
||||
@@ -195,7 +248,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 +286,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 +316,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) {
|
||||
@@ -363,11 +442,15 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* Shader pre-caching as the launcher last set it, until a session reports it written into the
|
||||
* client's config.vdf; after that the client's own toggle is the user's again.
|
||||
* client's config.vdf; after that the client's own toggle is the user's again. A client that
|
||||
* has never written a config.vdf starts with pre-caching off.
|
||||
*/
|
||||
fun pendingShaderPrecache(context: Context): Boolean? {
|
||||
val prefs = prefs(context)
|
||||
if (!prefs.contains("shaderPrecache")) return null
|
||||
if (!prefs.contains("shaderPrecache")) {
|
||||
if (steamConfig(context).exists()) return null
|
||||
prefs.edit().putBoolean("shaderPrecache", false).apply()
|
||||
}
|
||||
val on = prefs.getBoolean("shaderPrecache", true)
|
||||
val applied = shaderPrecacheApplied(context)
|
||||
val written = runCatching { applied.readText().trim() }.getOrNull() ?: return on
|
||||
@@ -382,9 +465,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("shaderPrecache", on).apply()
|
||||
}
|
||||
|
||||
private fun steamConfig(context: Context) =
|
||||
java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf")
|
||||
|
||||
fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context)
|
||||
?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache(
|
||||
java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf").readText()) }.getOrDefault(true)
|
||||
steamConfig(context).readText()) }.getOrDefault(true)
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
@@ -529,8 +615,18 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
|
||||
}
|
||||
|
||||
/** Whether the game's busiest threads are named to the power HAL for a boost (GameThreadHints). */
|
||||
fun gameThreadHints(context: Context): Boolean = prefs(context).getBoolean("gameThreadHints", true)
|
||||
/**
|
||||
* The performance boost: the game's busiest threads named to the power HAL (GameThreadHints) and,
|
||||
* on a Samsung phone with Samsung's SDK imported, its clock floors (SamsungPerf).
|
||||
*/
|
||||
fun performanceBoost(context: Context): Boolean = prefs(context).getBoolean("performanceBoost", GameThreadHints.measuredHere)
|
||||
|
||||
fun setPerformanceBoost(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("performanceBoost", on).apply()
|
||||
}
|
||||
|
||||
/** Hidden: offers the Samsung SDK import on any phone, to test the import where there is no Samsung. */
|
||||
fun samsungPerfAnyPhone(context: Context): Boolean = prefs(context).getBoolean("samsungPerfAnyPhone", false)
|
||||
|
||||
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
|
||||
|
||||
@@ -937,21 +1033,34 @@ object SessionPrefs {
|
||||
if (previous != null && !p.contains("addedAppId:$folderPath")) {
|
||||
val moved = movedAddedGame(p.all, previous, folderPath)
|
||||
moved.keys.forEach { edit.remove(it) }
|
||||
moved.values.forEach { (name, value) ->
|
||||
when (value) {
|
||||
is String -> edit.putString(name, value)
|
||||
is Long -> edit.putLong(name, value)
|
||||
is Int -> edit.putInt(name, value)
|
||||
is Boolean -> edit.putBoolean(name, value)
|
||||
is Float -> edit.putFloat(name, value)
|
||||
}
|
||||
}
|
||||
moved.values.forEach { (name, value) -> edit.putValue(name, value) }
|
||||
val removed = removedAddedGames(context)
|
||||
if (previous in removed) edit.putString("removedAddedGames", removed.map { if (it == previous) folderPath else it }.joinToString("\n"))
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
|
||||
fun inheritAddedGame(context: Context, from: String, to: String) = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
val all = p.all
|
||||
val edit = p.edit()
|
||||
all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.forEach { (name, value) ->
|
||||
val key = name.removeSuffix(from) + to
|
||||
if (key !in all) edit.putValue(key, value)
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
|
||||
private fun android.content.SharedPreferences.Editor.putValue(name: String, value: Any?) {
|
||||
when (value) {
|
||||
is String -> putString(name, value)
|
||||
is Long -> putLong(name, value)
|
||||
is Int -> putInt(name, value)
|
||||
is Boolean -> putBoolean(name, value)
|
||||
is Float -> putFloat(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun movedAddedGame(all: Map<String, *>, from: String, to: String): Map<String, Pair<String, Any?>> =
|
||||
all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.mapValues { (name, value) ->
|
||||
val moved = if (value is String && (value == from || value.startsWith("$from/"))) to + value.removePrefix(from) else value
|
||||
@@ -1009,12 +1118,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",
|
||||
@@ -1195,7 +1318,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()
|
||||
@@ -1238,7 +1361,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 }
|
||||
@@ -1264,6 +1388,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) ||
|
||||
|
||||
@@ -334,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
|
||||
@@ -498,6 +499,7 @@ class SessionService : Service() {
|
||||
|
||||
Log.i(TAG, GpuClockPin.start(this))
|
||||
Log.i(TAG, GameThreadHints.start(this))
|
||||
Log.i(TAG, SamsungPerf.start(this))
|
||||
|
||||
val size = SessionState.outputSize
|
||||
val guest = ArrayList<String>()
|
||||
@@ -535,10 +537,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)
|
||||
@@ -719,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")
|
||||
@@ -756,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(
|
||||
@@ -820,7 +819,7 @@ class SessionService : Service() {
|
||||
if (!SessionPrefs.zramClient(this)) guest.add("BL_ZRAM_CLIENT=0")
|
||||
}
|
||||
SessionPrefs.vramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_VRAM_MB=$it") }
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
|
||||
guest.add("AUDIO_DIRECT_DECAY=0")
|
||||
val extra = extraEnv() + agentEnvironment
|
||||
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
|
||||
extra.forEach { guest.add(it) }
|
||||
@@ -843,7 +842,7 @@ class SessionService : Service() {
|
||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
guest.add("BL_MIC_ENABLED=" + if (wantsMic) "1" else "0")
|
||||
guest.add("BANNER_AUDIO_DIRECT_MIC=" + if (wantsMic) "1" else "0")
|
||||
guest.add("AUDIO_DIRECT_MIC=" + if (wantsMic) "1" else "0")
|
||||
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
|
||||
val relaySocket = File(audioDir, "relay.sock")
|
||||
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
|
||||
@@ -871,7 +870,11 @@ class SessionService : Service() {
|
||||
}
|
||||
if (wantsDirectAudio) {
|
||||
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
|
||||
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
|
||||
guest.add("AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
|
||||
val lowLatency = com.droiddeck.launcher.audio.DirectAudioTiming.lowLatencyOutput()
|
||||
val timing = com.droiddeck.launcher.audio.DirectAudioTiming.environment(lowLatency)
|
||||
guest.addAll(timing)
|
||||
Log.i(TAG, "audio: Android output " + (if (lowLatency) "is low-latency; DirectAudio keeps its defaults" else "is not low-latency; DirectAudio uses " + timing.joinToString(" ")))
|
||||
}
|
||||
Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "")
|
||||
+ (if (wantsMic) " + microphone" else "") +
|
||||
@@ -880,7 +883,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()
|
||||
@@ -1038,7 +1041,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)
|
||||
@@ -1337,17 +1340,7 @@ class SessionService : Service() {
|
||||
|
||||
/** A Steam game is running: the client runs each one under its reaper, and Proton's script
|
||||
* waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */
|
||||
private fun steamGameRunning(): Boolean {
|
||||
val procs = File("/proc").listFiles() ?: return false
|
||||
for (p in procs) {
|
||||
if (!p.name.all(Char::isDigit)) continue
|
||||
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
|
||||
if (cmd.isEmpty()) continue
|
||||
val text = String(cmd).replace('\u0000', ' ')
|
||||
if (text.contains("SteamLaunch AppId=") || text.contains("waitforexitandrun")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
private fun steamGameRunning(): Boolean = SteamGameProcess.running()
|
||||
|
||||
private fun resumeSession(panelVisible: Boolean = true) {
|
||||
if (SessionState.suspended) SessionState.resumeRequested = true
|
||||
@@ -1819,6 +1812,7 @@ class SessionService : Service() {
|
||||
SessionState.stopRequested = false
|
||||
GpuClockPin.stop(this)
|
||||
GameThreadHints.stop()
|
||||
SamsungPerf.stop()
|
||||
suspendOperationPending = false
|
||||
freezePending = false
|
||||
launchWatcher?.stopWatching()
|
||||
@@ -1882,6 +1876,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
|
||||
@@ -1896,6 +1893,7 @@ class SessionService : Service() {
|
||||
override fun onDestroy() {
|
||||
stopSession(0)
|
||||
GameThreadHints.stop() // also when the session never got as far as running
|
||||
SamsungPerf.stop()
|
||||
if (screenReceiverRegistered) {
|
||||
unregisterReceiver(screenReceiver)
|
||||
screenReceiverRegistered = false
|
||||
|
||||
@@ -91,6 +91,8 @@ object SessionState {
|
||||
|
||||
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
|
||||
@Volatile var secondScreenDisplay = -1
|
||||
@Volatile var secondScreenAutoOpened = false
|
||||
@Volatile var secondScreenProvisional = false
|
||||
|
||||
@Volatile
|
||||
var mode = "steam"
|
||||
@@ -107,6 +109,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,13 +12,14 @@ 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",
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "performanceBoost", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
|
||||
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "storageDiagnostics",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
|
||||
object SteamGameProcess {
|
||||
fun matches(cmdline: ByteArray): Boolean {
|
||||
val args = String(cmdline).split('\u0000')
|
||||
for (i in args.indices) {
|
||||
if (args[i] == "waitforexitandrun") return true
|
||||
if (args[i] == "SteamLaunch" && args.getOrNull(i + 1)?.startsWith("AppId=") == true) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun running(proc: File = File("/proc")): Boolean = proc.listFiles()?.any { p ->
|
||||
p.name.all(Char::isDigit) && runCatching { File(p, "cmdline").readBytes() }.getOrNull()?.let(::matches) == true
|
||||
} == true
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Loaded 100 of 633 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user