mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26fa0981c | ||
|
|
ec24b3a472 | ||
|
|
6455b1dade | ||
|
|
542ad3e060 | ||
|
|
910fb47692 | ||
|
|
3dd14dd625 | ||
|
|
d1b0ac4df1 | ||
|
|
0e225d0c6e | ||
|
|
d9cd0d2cc8 | ||
|
|
eadebc46d5 | ||
|
|
96d7c66535 | ||
|
|
90690d1c5c | ||
|
|
abbcb8e3ad | ||
|
|
fe60b3c279 | ||
|
|
c7455b74ff | ||
|
|
c875ceda4d | ||
|
|
666abd134f | ||
|
|
c115916962 | ||
|
|
0579e688ba | ||
|
|
bc50de3b55 | ||
|
|
6489232598 | ||
|
|
c23c436502 | ||
|
|
fad52ec125 | ||
|
|
d22e458d64 | ||
|
|
1b7cb4179d | ||
|
|
c1c22dfd37 | ||
|
|
0043cefab5 | ||
|
|
41f68ef1a1 | ||
|
|
9743ac3abc | ||
|
|
ca6cfcd193 | ||
|
|
d41424170a | ||
|
|
f4678935c4 | ||
|
|
416eb41d8d | ||
|
|
6e0d456dd7 | ||
|
|
8ac555664a | ||
|
|
4bf4e53ecf | ||
|
|
3d50e6b1ae | ||
|
|
846b20f78a | ||
|
|
a229d196bb | ||
|
|
61bfc04704 | ||
|
|
9b90386e73 | ||
|
|
ddc4c18faf | ||
|
|
fb8361b197 | ||
|
|
a5fd64f9f2 | ||
|
|
e0a7ce4f88 | ||
|
|
b213ae77b4 | ||
|
|
6e5932cb68 | ||
|
|
38e3fa2a33 | ||
|
|
8e1a676f19 | ||
|
|
7c946db440 | ||
|
|
eaf26c8a20 | ||
|
|
0a96767657 | ||
|
|
32054e156e | ||
|
|
fd6d6b7c46 | ||
|
|
c101a451e3 | ||
|
|
a76ba69217 | ||
|
|
d06caf9838 | ||
|
|
134fa167fe | ||
|
|
da42a5d7a7 | ||
|
|
b6c1d3c09c | ||
|
|
a58b0ac3e0 | ||
|
|
e1700ba982 | ||
|
|
736ac7db74 | ||
|
|
6e2966be43 | ||
|
|
679948e792 | ||
|
|
e0fc8362b8 | ||
|
|
9d8ff9615f | ||
|
|
1304809240 | ||
|
|
343a516e1a | ||
|
|
7c08cb58ae | ||
|
|
7e81668de2 | ||
|
|
d231564f8e | ||
|
|
37e89c5b4d | ||
|
|
e070bd72e0 |
No files matched your search
@@ -6,7 +6,6 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/aaudio-sink/**'
|
||||
- 'tools/directaudio-relay/**'
|
||||
- '.github/workflows/build-aaudio-sink.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -26,7 +25,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
|
||||
tools/directaudio-relay/build.sh "$PWD/out"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audio-sink-modules
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Build desktop package (KDE Plasma)
|
||||
|
||||
# The desktop the app installs into its Linux runtime on first use: KDE Plasma as SteamOS's desktop
|
||||
# mode has it, the closure of tools/desktop-kde/seeds.txt over Arch Linux ARM minus what the runtime
|
||||
# already carries, plus tools/desktop-kde/overlay (tools/desktop-kde/build.sh), every package
|
||||
# signature-checked. Published as a release in Droid-Deck/DroidDeck-Components with DroidDeck's
|
||||
# other components; the apk pins it in tools/desktop-kde/release.env (tag, sha256, size). A
|
||||
# published tag is never rebuilt: the packages are Arch Linux ARM's current ones, so a rebuild
|
||||
# would not match the pinned sha256. A changed package gets a new tag (desktop-kde-r2, ...) and a
|
||||
# release.env update.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/desktop-kde/**'
|
||||
- '.github/workflows/build-desktop-kde.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. desktop-kde-r1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
kwin:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build KWin with nested desktop scaling
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv patch binutils >/dev/null
|
||||
bash tools/desktop-kde/build-kwin.sh out/kwin
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde-kwin
|
||||
retention-days: 7
|
||||
path: out/kwin/
|
||||
build:
|
||||
needs: kwin
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: desktop-kde-kwin
|
||||
path: out/kwin
|
||||
- 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 xz-utils gpgv >/dev/null
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" 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)"
|
||||
# The package itself too, briefly: enough to try a build on a device before publishing it.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde
|
||||
retention-days: 7
|
||||
compression-level: 0
|
||||
path: |
|
||||
out/desktop-kde.tar.zst
|
||||
out/desktop-kde.packages.txt
|
||||
out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.sha256
|
||||
- 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 }}'
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
|
||||
--notes "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
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Build wlroots (patched)
|
||||
|
||||
# The desktop's wlroots (Arch Linux ARM's wlroots0.20, what the desktop package's labwc links)
|
||||
# rebuilt with tools/wlroots/patches: a Vulkan DMA-BUF allocator for the Adreno node, which is not
|
||||
# a DRM device, so labwc's vulkan and gles2 renderers can start. Built in an Arch Linux ARM
|
||||
# container on an arm64 runner and published as a release asset the apk build fetches
|
||||
# (tools/wlroots/release.env names the tag and sha). The hosted desktop package is never changed.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/wlroots/**'
|
||||
- '.github/workflows/build-wlroots.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. wlroots-0.20.2-p1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build in Arch Linux ARM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/wlroots/build-in-arch.sh
|
||||
sudo chown -R "$(id -u):$(id -g)" .
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wlroots-patched
|
||||
path: |
|
||||
wlroots.tzst
|
||||
wlroots.tzst.sha256
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber
|
||||
# A component the apk fetches by tag, never something users install: it must not
|
||||
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
+129
-80
@@ -44,6 +44,19 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check co-authors
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
messages=$(git log --format=%B "$base..HEAD")
|
||||
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
|
||||
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
|
||||
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
@@ -173,7 +186,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -195,6 +208,9 @@ jobs:
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \
|
||||
-fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so"
|
||||
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
|
||||
@@ -216,14 +232,6 @@ jobs:
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
rm gamescope.tzst
|
||||
fi
|
||||
if [ -f tools/wlroots/release.env ]; then
|
||||
. tools/wlroots/release.env
|
||||
gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst
|
||||
echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D"
|
||||
test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
|
||||
rm wlroots.tzst
|
||||
fi
|
||||
. tools/linuxfs/uruntime.env
|
||||
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
|
||||
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
|
||||
@@ -262,10 +270,13 @@ jobs:
|
||||
done
|
||||
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
||||
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
||||
# 7-Zip (the full build with its format plugin) opens installer .exe wrappers.
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
@@ -273,17 +284,42 @@ jobs:
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
|
||||
install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml"
|
||||
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
|
||||
install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
|
||||
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
|
||||
ls -lR "$D"
|
||||
|
||||
# The Debian 11 image the preloads are compiled in comes from a release on this repo
|
||||
# (published once by ci-debian-image.yml, pinned in tools/linuxfs/debian-image.env): the
|
||||
# public registry mirrors rate-limit the shared runner addresses and failed builds on a
|
||||
# busy evening. Only if that download fails is the image pulled, with retries.
|
||||
- name: Fetch the Debian image the preloads build in
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. tools/linuxfs/debian-image.env
|
||||
image=public.ecr.aws/docker/library/debian:bullseye
|
||||
if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \
|
||||
&& { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then
|
||||
zstd -dc /tmp/debian-image.tar.zst | docker load
|
||||
rm -f /tmp/debian-image.tar.zst
|
||||
else
|
||||
echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
docker pull "$image" && break
|
||||
[ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; }
|
||||
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
fi
|
||||
docker image ls "$image"
|
||||
|
||||
- name: Build the x86 preloads FEX guests load (glibc 2.31)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/src" -w /src debian:bullseye bash -c '
|
||||
docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
|
||||
set -euo pipefail
|
||||
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -qq
|
||||
@@ -331,6 +367,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
|
||||
# DirectAudio - the game driver (one set per Wine audio interface), the Android-side helper and
|
||||
# the Steam client's sinks - comes from a pinned release of The412Banner/directaudio, checksummed,
|
||||
# exactly like gamescope; nothing compiled for audio is kept in this repo.
|
||||
- name: Fetch DirectAudio (tools/directaudio/release.env)
|
||||
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
|
||||
- name: Build the AAudio sink into the audio bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -340,17 +381,12 @@ jobs:
|
||||
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
|
||||
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
|
||||
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
mkdir -p app/build/prepared-assets
|
||||
mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
- name: Build the DirectAudio relay
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
uses: actions/cache@v4
|
||||
@@ -369,42 +405,40 @@ jobs:
|
||||
- name: Check the preloads
|
||||
run: |
|
||||
set -euo pipefail
|
||||
D=app/src/main/assets/linuxfs
|
||||
NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
|
||||
for bad in libstdc++.so.6 libgcc_s.so.1; do
|
||||
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
|
||||
echo "::error::libfakeinput.so links $bad - the C++ runtime must stay static"; exit 1
|
||||
fi
|
||||
done
|
||||
syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; }
|
||||
FAKE=$(syms "$D/libfakeinput.so")
|
||||
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
|
||||
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# The static C++ runtime stays private: every session process preloads this library,
|
||||
# and an exported personality routine takes over other programs' unwinding (shadPS4
|
||||
# aborted in pthread_exit).
|
||||
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
|
||||
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
|
||||
if grep -qx drmIoctl <<<"$SESSION"; then
|
||||
echo "::error::libblsession.so exports drmIoctl"; exit 1
|
||||
fi
|
||||
SSBS=$(syms "$D/libssbs.so")
|
||||
for sym in sigaction signal bsd_signal sysv_signal; do
|
||||
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
|
||||
done
|
||||
test -f "$D/usr/local/bin/droiddeck-session"
|
||||
test -f "$D/usr/local/bin/droiddeck-proton-extra"
|
||||
test -f "$D/usr/local/bin/droiddeck-msi-install"
|
||||
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
|
||||
done
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
|
||||
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
|
||||
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
|
||||
# target and cargo-ndk are added here. Cache = cargo registry + cargo-ndk + the crate's target
|
||||
# tree, keyed on the rustc version and Cargo.lock, so a source-only change rebuilds this crate
|
||||
# on top of the cached dependency objects, and a lockfile change still starts from the
|
||||
# previous objects (restore-keys).
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
id: rust
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup target add aarch64-linux-android
|
||||
echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
rustc -V; cargo -V
|
||||
|
||||
- name: Cache cargo registry and the store engines' target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-ndk
|
||||
app/src/main/rust/stores/target
|
||||
key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
stores-${{ steps.rust.outputs.rustc }}-
|
||||
|
||||
- name: cargo-ndk
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v cargo-ndk >/dev/null || cargo install cargo-ndk --locked
|
||||
cargo ndk --version
|
||||
|
||||
- name: Cache the CMake build tree
|
||||
uses: actions/cache@v4
|
||||
@@ -420,10 +454,46 @@ jobs:
|
||||
id: build
|
||||
env:
|
||||
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 tools/release/runtime_inputs.py record
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
||||
python3 tools/release/runtime_inputs.py apk app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Check the store engines' library
|
||||
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
|
||||
# the device with an UnsatisfiedLinkError instead of here. The lockfile check is the
|
||||
# reproducibility guard: cargo must not have had to re-resolve anything.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
python3 tools/release/check_android_libraries.py app/build/outputs/apk/release/app-release.apk \
|
||||
--readelf "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
|
||||
git diff --stat -- app/src/main/rust/stores/Cargo.lock
|
||||
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
|
||||
# The crate's #[test] functions are pure host-side logic (manifest parsing, chunk plans,
|
||||
# assembly from a cache, hashes), so a plain `cargo test` on the runner's own architecture
|
||||
# runs them; the Android build above compiles none of them. Informational on this first
|
||||
# run - the point is to learn whether they pass - and bounded so a hang can never hold
|
||||
# the apk. Make it blocking once it is known green.
|
||||
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
|
||||
with:
|
||||
name: stores-cargo-lock
|
||||
path: app/src/main/rust/stores/Cargo.lock
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
@@ -431,27 +501,6 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Check the native dependency closure
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
||||
WORK=$(mktemp -d)
|
||||
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
|
||||
cd "$WORK/lib/arm64-v8a"
|
||||
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
|
||||
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
|
||||
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
|
||||
fail=0
|
||||
for so in *.so; do
|
||||
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
|
||||
[ -f "$need" ] && continue
|
||||
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
|
||||
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
|
||||
fail=1
|
||||
done
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Sign with the testkey (v1 + v2 + v3)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish the CI Debian image
|
||||
|
||||
# Pulls the Debian 11 image the x86 preloads build in, once, and publishes it as a release asset
|
||||
# on this repo so build.yml never depends on a registry mirror's rate limit. Run it again (with a
|
||||
# new tag) only to refresh the image; tools/linuxfs/debian-image.env names the one builds use.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish to (a new -rN tag for a refresh; builds pin the one in tools/linuxfs/debian-image.env)'
|
||||
required: true
|
||||
default: 'ci-debian-bullseye-r2'
|
||||
replace:
|
||||
description: 'Overwrite the asset if the release already has one (changes its checksum: re-pin it in debian-image.env)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Pull, save and publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
REPLACE: ${{ inputs.replace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=public.ecr.aws/docker/library/debian:bullseye
|
||||
# A published image is pinned by checksum in the build; never replace one by accident.
|
||||
if [ "$REPLACE" != true ] && gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' 2>/dev/null | grep -qx debian-bullseye.tar.zst; then
|
||||
echo "::error::$TAG already carries debian-bullseye.tar.zst; publish to a new tag, or set replace and re-pin tools/linuxfs/debian-image.env"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
docker pull "$image" && break
|
||||
[ "$attempt" -lt 6 ] || { echo "::error::could not pull $image"; exit 1; }
|
||||
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
docker image inspect "$image" --format 'image id {{.Id}} created {{.Created}}'
|
||||
docker save "$image" | zstd -19 -T0 -o debian-bullseye.tar.zst
|
||||
sha256sum debian-bullseye.tar.zst | tee SHA256SUMS
|
||||
ls -l debian-bullseye.tar.zst
|
||||
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" -R "$GITHUB_REPOSITORY" --title "CI Debian 11 image ($TAG)" --latest=false \
|
||||
--notes "The debian:bullseye image build.yml compiles the x86 preloads in (glibc 2.31), saved with docker save and compressed with zstd. Pulled from public.ecr.aws/docker/library/debian:bullseye on $(date -u +%Y-%m-%d); image id $(docker image inspect "$image" --format '{{.Id}}'). Pinned in tools/linuxfs/debian-image.env."
|
||||
fi
|
||||
gh release upload "$TAG" -R "$GITHUB_REPOSITORY" debian-bullseye.tar.zst SHA256SUMS --clobber
|
||||
{ echo "Published \`$TAG\`:"; echo; echo '```'; cat SHA256SUMS; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -13,13 +13,28 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check the current PR state
|
||||
id: state
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
|
||||
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
@@ -27,6 +42,7 @@ jobs:
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete the test build
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -42,6 +58,19 @@ jobs:
|
||||
done
|
||||
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- name: Retire the Discord PR announcement
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: python3 tools/release/pr_discord.py retire
|
||||
|
||||
catalog:
|
||||
needs: cleanup
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -26,6 +26,7 @@ permissions:
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
@@ -178,6 +179,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-pr-signed-*
|
||||
@@ -307,39 +312,27 @@ jobs:
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
|
||||
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
|
||||
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
|
||||
id=$(jq -r '.id // empty' <<<"$comment")
|
||||
if [ -n "$id" ]; then
|
||||
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
|
||||
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
|
||||
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
|
||||
# only a PR's first test build is, and later ones update its release quietly.
|
||||
- name: Post to Discord
|
||||
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
|
||||
- name: Update the Discord PR announcement
|
||||
if: ${{ always() && steps.publish.outcome == 'success' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
short=${HEAD_SHA:0:7}
|
||||
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
|
||||
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
run: python3 tools/release/pr_discord.py publish
|
||||
|
||||
catalog:
|
||||
needs: publish
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
|
||||
@@ -13,8 +13,12 @@ captures/
|
||||
app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-outputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
|
||||
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
|
||||
# app/build/rustJniLibs; cargo's own build tree stays next to the crate. Neither is source.
|
||||
app/src/main/rust/stores/target/
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
@@ -35,3 +39,4 @@ __pycache__/
|
||||
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
app/src/main/assets/directaudio/
|
||||
@@ -15,21 +15,25 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
|
||||
|
||||
Before Steam launches, you must turn off **Restrict child processes** 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.
|
||||
|
||||
## Community
|
||||
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it.
|
||||
|
||||
## Build
|
||||
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust through rustup, GitHub CLI, Python 3, and `zstd` installed. It prepares the same native libraries, pinned components and audio sinks as CI, then checks the APK's assets, JNI exports and dependencies. The APK is at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. Set `DROIDDECK_SIGNING_ENV` to a signing environment file to install over a release build; relative keystore paths resolve beside that file. To install on an attached device, run `tools/deploy_local.sh`.
|
||||
|
||||
After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too.
|
||||
|
||||
Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers.
|
||||
|
||||
## Limits
|
||||
|
||||
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems.
|
||||
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. Logs are kept app-private (`files/logs/`, the last 30 sessions); **Share logs** packs the newest session, with the stores and tool logs, into one scrubbed zip.
|
||||
|
||||
## Credits and licence
|
||||
|
||||
|
||||
+90
-2
@@ -59,6 +59,19 @@ def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'tr
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// The desktop package this apk installs (tools/desktop-kde/release.env): published once per tag in
|
||||
// DroidDeck-Components, pinned here with its checksum like gamescope and the esync packs, so the
|
||||
// desktop scripts in the apk always get the package they were made for.
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
|
||||
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
|
||||
throw new GradleException('Desktop removal inventories do not match the pinned archive')
|
||||
}
|
||||
if (!file('src/main/assets/desktop-removal/versions.txt').readLines().contains(desktopKde.DESKTOP_KDE_TAG)) {
|
||||
throw new GradleException('Regenerate desktop removal inventories for ' + desktopKde.DESKTOP_KDE_TAG)
|
||||
}
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
|
||||
.findAll { it.trim() && !it.startsWith('#') }
|
||||
@@ -79,8 +92,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 11
|
||||
versionName "0.3.1"
|
||||
versionCode 12
|
||||
versionName "0.3.2"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -88,6 +101,11 @@ android {
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
|
||||
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
|
||||
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
@@ -188,6 +206,76 @@ def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
|
||||
|
||||
// Prepared audio lives under build/, so native builds never rewrite the tracked base bundle.
|
||||
// Copying into one asset root also prevents an old base bundle winning a duplicate asset merge.
|
||||
def runtimeAssetsDir = layout.buildDirectory.dir('runtimeAssets')
|
||||
def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
|
||||
dependsOn(stageSessionScripts)
|
||||
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
|
||||
from(layout.buildDirectory.dir('prepared-assets'))
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
|
||||
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
|
||||
|
||||
// Unit tests and previews remain usable without Docker. Packaging an APK requires the complete
|
||||
// runtime payload prepared by the local helper or CI, with matching sources and output hashes.
|
||||
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
|
||||
// The session scripts are re-copied into the asset tree by stageSessionScripts; with parallel
|
||||
// task execution this check once hashed a file mid-copy and failed a main build. Hash only
|
||||
// once the staging has finished.
|
||||
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
|
||||
workingDir rootProject.projectDir
|
||||
commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify'
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
|
||||
}
|
||||
|
||||
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
|
||||
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
|
||||
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
|
||||
// the preloads, proot and the DirectAudio relay follow. cargo-ndk exports the NDK clang/llvm-ar as
|
||||
// the C compiler for the one C-compiling crate (ring, under rustls) - the step Bannerlator's CI
|
||||
// does by hand - and reads the NDK from ANDROID_NDK_HOME, which is set to the NDK this build pins.
|
||||
// -PskipRust=true builds without the library, for a checkout with no Rust toolchain: the stores
|
||||
// then report themselves unavailable instead of the build failing.
|
||||
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
|
||||
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
|
||||
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
|
||||
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.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
|
||||
inputs.property('ndkVersion', android.ndkVersion)
|
||||
if (!skipRust) inputs.property('rustToolchain', providers.exec {
|
||||
environment 'PATH', "${System.getProperty('user.home')}/.cargo/bin${File.pathSeparator}${System.getenv('PATH')}"
|
||||
commandLine 'rustup', 'run', 'stable', 'rustc', '-Vv'
|
||||
}.standardOutput.asText)
|
||||
outputs.dir(rustJniLibsDir)
|
||||
workingDir rustCrateDir
|
||||
// The configuration cache serialises the task, so everything it needs is resolved here, not
|
||||
// in doFirst. ~/.cargo/bin is where rustup puts cargo and cargo-ndk; an IDE-launched Gradle
|
||||
// does not always have it on PATH.
|
||||
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
|
||||
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
|
||||
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
|
||||
environment 'CARGO_TERM_COLOR', 'never'
|
||||
if (!skipRust) environment 'RUSTC', providers.exec {
|
||||
commandLine 'rustup', 'which', '--toolchain', 'stable', 'rustc'
|
||||
}.standardOutput.asText.get().trim()
|
||||
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
|
||||
// Homebrew's cargo/rustc can precede rustup on macOS and use a different sysroot, despite
|
||||
// rustup reporting the Android target installed. Run both through the selected toolchain.
|
||||
commandLine 'rustup', 'run', 'stable', 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
|
||||
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
|
||||
'build', '--release', '--locked'
|
||||
}
|
||||
// Skipping Rust must omit its library even if another build left a cached .so behind.
|
||||
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Store download progress and "installed" / "failed" notices (Android 13+). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- The session keeps running while the app is not on screen: a foreground service holds the
|
||||
process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded
|
||||
download from being throttled to nothing by the radio's power-save. -->
|
||||
@@ -28,6 +30,9 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
|
||||
Granted at install below targetSdk 31; no scan, so no location. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
@@ -127,6 +132,13 @@
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<!-- A store's own sign-in page (GOG, Epic Games, Amazon Games) in a WebView. -->
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
@@ -163,6 +175,11 @@
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
|
||||
<service
|
||||
android:name=".stores.download.StoreDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"archives": {
|
||||
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
|
||||
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
|
||||
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
|
||||
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"legacyApplications": {
|
||||
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
|
||||
"labwc-0.20.2-1-aarch64.pkg.tar.xz": "bcd4cc3945165b0fdd33c0117cd7b9dfd05cf8c327326c37f3c3a27ef23ee5ca",
|
||||
"lximage-qt-2.4.0-1-aarch64.pkg.tar.xz": "5d6bdd5472ea6bcb4b993b36df3ab7010bb2e62b36a78a649708fc9a52cb0928",
|
||||
"lxqt-config-2.4.0-1-aarch64.pkg.tar.xz": "4b601d798fc7689ee76787dbef8c8958c02429ee60248cd99d950c919dbefba5",
|
||||
"lxqt-panel-2.4.1-1-aarch64.pkg.tar.xz": "a141e0f8049e73f859d843510c6f45d6ed9087c990bcebf44f5f2430621024c4",
|
||||
"lxqt-qtplugin-2.4.0-3-aarch64.pkg.tar.xz": "61c8f828be73111ee0bd5e3163b0d64c2098dd0189710a190f42be11137695cf",
|
||||
"lxqt-runner-2.4.0-1-aarch64.pkg.tar.xz": "5afa74652a37366ea4e7774cf33379977582fbdca599cef1005ebf637cbcae4b",
|
||||
"lxqt-session-2.4.0-2-aarch64.pkg.tar.xz": "e9c7c8a0011859029622ad202e0d3826e4e5bc8d64659031f6efa211456ff6eb",
|
||||
"lxqt-themes-2.4.0-1-any.pkg.tar.xz": "7ea16c62a1fa70cdac13cb3f8fdcdaad299ae1f5bd514acfda671484755787bf",
|
||||
"lxqt-wayland-session-0.4.1-1-any.pkg.tar.xz": "cd8dc11f5035469d23b6bbd937a90593402428c43de37517412aa43fe1fc6b10",
|
||||
"papirus-icon-theme-20260801-1-any.pkg.tar.xz": "4aae3f47b800a60d6fd360e55adc581408ceaafc481c5a420a681741a3966510",
|
||||
"pcmanfm-qt-2.4.1-1-aarch64.pkg.tar.xz": "7ec1a6b39dd0e4055b39f0e2c0549ea263d83004e1b821fb9e42f1d7f5fff0f9",
|
||||
"qterminal-2.4.0-1-aarch64.pkg.tar.xz": "aa4adc9c9d47641b9452ec8d879038a9cb4762c3b6b5299045fac968fd0ed22d",
|
||||
"swaybg-1.2.2-1-aarch64.pkg.tar.xz": "a26caa16ca7d581b5ca05758677058b4b60af10dc8d83e447ad6824b762b48b8",
|
||||
"wlr-randr-0.5.0-1-aarch64.pkg.tar.xz": "58c3651c6a15dc8f95a310c7e225f759aa766be2fb3c0981a9a8b567854675b3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
desktop-kde-r1
|
||||
desktop-kde-r2
|
||||
@@ -1,115 +0,0 @@
|
||||
# DirectAudio for the Linux Steam client (relay build)
|
||||
|
||||
This is the driver for games that run under **Valve's ARM64 Proton inside a Linux
|
||||
runtime on Android** - Bannerlator's linuxfs session with the native Steam client -
|
||||
where the game process is a glibc process and cannot touch Android's audio
|
||||
libraries itself.
|
||||
|
||||
Two pieces, both required:
|
||||
|
||||
| piece | what it is | where it runs |
|
||||
|---|---|---|
|
||||
| `aarch64-unix/winedirectaudio.so` + the two PE shells | the Wine `mmdevapi` driver, glibc build, **no AAudio inside** | in the game, inside the Linux rootfs |
|
||||
| `directaudio-relay` | a small bionic program that owns the AAudio output and microphone streams | on the Android side, under the app's uid |
|
||||
|
||||
They meet over a unix socket. The driver connects, sends its launch config, and
|
||||
gets back two shared-memory rings: one it fills with mixed game audio, one the
|
||||
helper fills with microphone audio. The helper plays and records; the driver
|
||||
keeps doing everything else it always did.
|
||||
|
||||
## Which Proton
|
||||
|
||||
Built inside `ValveSoftware/wine` at `proton_11.0`. The private `mmdevapi`
|
||||
interface it implements is byte-identical across **Proton 11.0 (ARM64)**,
|
||||
**Proton Experimental (ARM64)** and our own Wine-11 layers (checked 2026-09-19),
|
||||
so this one build serves any of them. A future Proton on a Wine 12 base will
|
||||
need a rebuild - the interface is not versioned, and a mismatch is silence, not
|
||||
an error.
|
||||
|
||||
## Installing the driver next to Proton (not into it)
|
||||
|
||||
Steam verifies and updates its Proton depots, so files added under
|
||||
`steamapps/common/Proton .../files/lib/wine/` will not survive. Put the driver
|
||||
in a directory of its own and point Wine at it:
|
||||
|
||||
```
|
||||
<somewhere>/directaudio/lib/wine/aarch64-unix/winedirectaudio.so
|
||||
<somewhere>/directaudio/lib/wine/aarch64-windows/winedirectaudio.drv
|
||||
<somewhere>/directaudio/lib/wine/i386-windows/winedirectaudio.drv
|
||||
```
|
||||
|
||||
and set, for the game process:
|
||||
|
||||
```
|
||||
WINEDLLPATH=<somewhere>/directaudio/lib/wine
|
||||
```
|
||||
|
||||
Wine searches `WINEDLLPATH` entries with the per-arch subdirectories appended,
|
||||
so both the PE shell and the unixlib are found there. In Bannerlator's runtime
|
||||
the natural place to export it is the `bannerlator-proton` compatibility-tool
|
||||
wrapper, which every Proton launch goes through.
|
||||
|
||||
## Selecting the driver
|
||||
|
||||
`mmdevapi` only tries the drivers named in the registry (or its built-in
|
||||
`pulse,alsa,oss,coreaudio` list, which does not know about this one). Set, in the
|
||||
game's prefix:
|
||||
|
||||
```
|
||||
wine reg add "HKCU\Software\Wine\Drivers" /v Audio /d directaudio /f
|
||||
```
|
||||
|
||||
Under Proton the prefix is `compatdata/<appid>/pfx`; run the command with that
|
||||
prefix as `WINEPREFIX` using Proton's own `bin-arm64/wine`, or write the key from
|
||||
the wrapper before it hands off to Proton.
|
||||
|
||||
## Running the helper
|
||||
|
||||
```
|
||||
directaudio-relay --socket <path> [--log]
|
||||
```
|
||||
|
||||
- Run it **under the app's uid**, the way the app already runs its PulseAudio
|
||||
daemon. Android checks `RECORD_AUDIO` against the calling uid, so a helper
|
||||
started any other way can play but cannot record.
|
||||
- Start it **before** the game: the driver asks "is a helper there?" once, when
|
||||
Wine picks an audio driver, and falls back to unavailable if not.
|
||||
- The socket path must be reachable from inside the Linux rootfs. Bannerlator
|
||||
binds the app's files directory and its runtime directory into the session at
|
||||
their own paths, so any socket under either works unchanged on both sides.
|
||||
- The helper serves any number of game processes; each gets its own streams.
|
||||
It exits on `SIGTERM`; a game exiting tears down that game's streams only.
|
||||
|
||||
## Telling the driver where the helper is
|
||||
|
||||
```
|
||||
BANNER_AUDIO_DIRECT_RELAY=<the same socket path>
|
||||
```
|
||||
|
||||
Unset, the driver looks for `$XDG_RUNTIME_DIR/directaudio-relay`.
|
||||
|
||||
## Everything else is unchanged
|
||||
|
||||
All `BANNER_AUDIO_DIRECT_*` knobs mean what they mean in the in-process build:
|
||||
`_PERF`, `_ADAPTIVE`, `_DECAY`, `_MS`, `_MAXMS`, `_BF`, `_MBF`, `_EXCLUSIVE`,
|
||||
`_WATCHDOG`, `_STALL_MS`, `_DECAY_*`, `_LOG`, `_PERIOD_MS`, `_MINPERIOD_MS`, and
|
||||
the live-config mailbox `_RUNTIME` (the helper watches the file; it must be a
|
||||
path the helper can read on the Android side). **`BANNER_AUDIO_DIRECT_MIC=1`**
|
||||
exposes the capture endpoint exactly as before: the helper opens the input
|
||||
stream (`VOICE_COMMUNICATION` preset) when the game connects and makes it hot on
|
||||
the game's first capture start.
|
||||
|
||||
One thing is different: there is one extra hand-off, the ring. The helper's
|
||||
callback pulls a burst from it, so the driver keeps two bursts queued (about 8 ms
|
||||
on a 192-frame device) on top of the AAudio buffer the presets control, and the
|
||||
helper raises that if a callback ever finds the ring short. `get_latency` reports
|
||||
the sum, so a game that sizes its own buffers from it still sees the truth.
|
||||
|
||||
## Proving it is running
|
||||
|
||||
- Helper side: `logcat -s DA-Relay:I` shows `hello from <exe>`, then `open: buffer
|
||||
... ring target ...`, then `capture open` / `capture start` for the mic.
|
||||
- Game side: the session log carries `DirectAudio: relay: connected - ...`.
|
||||
- The game process's `/proc/<pid>/maps` shows `winedirectaudio.so` and **no**
|
||||
`libaaudio.so` (it is in the helper's maps instead). The AudioFlinger track is
|
||||
owned by the helper's pid.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
directaudio linux-wine11 relay build
|
||||
source 3548a4a730e8d01cb0cb0268d51a2fb8f23dcd33
|
||||
wine ValveSoftware/wine@proton_11.0
|
||||
unixlib.h sha256 fdbed263338d0719c4d12e02b045c23721de33219ec8878e4c2d7ebc9efcbe09
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,6 +136,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
|
||||
argv[i] = strdup(arg_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
|
||||
(*env)->DeleteLocalRef(env, arg_java_string);
|
||||
}
|
||||
argv[size] = NULL;
|
||||
}
|
||||
@@ -151,6 +152,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
|
||||
envp[i] = strdup(env_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
|
||||
(*env)->DeleteLocalRef(env, env_java_string);
|
||||
}
|
||||
envp[size] = NULL;
|
||||
}
|
||||
@@ -160,7 +162,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
|
||||
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
|
||||
(*env)->ReleaseStringUTFChars(env, cwd, cmd_cwd);
|
||||
|
||||
if (argv) {
|
||||
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
|
||||
|
||||
@@ -17,6 +17,7 @@ add_library(droiddeckwayland SHARED
|
||||
src/wl_clipboard.c
|
||||
src/wl_text_input.c
|
||||
src/wl_toplevel_icon.c
|
||||
src/wl_single_pixel.c
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
@@ -31,6 +32,7 @@ add_library(droiddeckwayland SHARED
|
||||
generated/wlr-data-control-unstable-v1-protocol.c
|
||||
generated/text-input-unstable-v3-protocol.c
|
||||
generated/xdg-toplevel-icon-v1-protocol.c
|
||||
generated/single-pixel-buffer-v1-protocol.c
|
||||
generated/banner-ahb-v1-protocol.c
|
||||
src/framegen_bridge.c
|
||||
src/framegen_jni.c
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* Generated by wayland-scanner 1.26.0 */
|
||||
|
||||
/*
|
||||
* Copyright © 2022 Simon Ser
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include "wayland-util.h"
|
||||
|
||||
#ifndef __has_attribute
|
||||
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
|
||||
#endif
|
||||
|
||||
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
|
||||
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
|
||||
#else
|
||||
#define WL_PRIVATE
|
||||
#endif
|
||||
|
||||
extern const struct wl_interface wl_buffer_interface;
|
||||
|
||||
static const struct wl_interface *single_pixel_buffer_v1_types[] = {
|
||||
&wl_buffer_interface,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
};
|
||||
|
||||
static const struct wl_message wp_single_pixel_buffer_manager_v1_requests[] = {
|
||||
{ "destroy", "", single_pixel_buffer_v1_types + 0 },
|
||||
{ "create_u32_rgba_buffer", "nuuuu", single_pixel_buffer_v1_types + 0 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface wp_single_pixel_buffer_manager_v1_interface = {
|
||||
"wp_single_pixel_buffer_manager_v1", 1,
|
||||
2, wp_single_pixel_buffer_manager_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Generated by wayland-scanner 1.26.0 */
|
||||
|
||||
#ifndef SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
|
||||
#define SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "wayland-server.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct wl_client;
|
||||
struct wl_resource;
|
||||
|
||||
/**
|
||||
* @page page_single_pixel_buffer_v1 The single_pixel_buffer_v1 protocol
|
||||
* single pixel buffer factory
|
||||
*
|
||||
* @section page_desc_single_pixel_buffer_v1 Description
|
||||
*
|
||||
* This protocol extension allows clients to create single-pixel buffers.
|
||||
*
|
||||
* Compositors supporting this protocol extension should also support the
|
||||
* viewporter protocol extension. Clients may use viewporter to scale a
|
||||
* single-pixel buffer to a desired size.
|
||||
*
|
||||
* Warning! The protocol described in this file is currently in the testing
|
||||
* phase. Backward compatible changes may be added together with the
|
||||
* corresponding interface version bump. Backward incompatible changes can
|
||||
* only be done by creating a new major version of the extension.
|
||||
*
|
||||
* @section page_ifaces_single_pixel_buffer_v1 Interfaces
|
||||
* - @subpage page_iface_wp_single_pixel_buffer_manager_v1 - global factory for single-pixel buffers
|
||||
* @section page_copyright_single_pixel_buffer_v1 Copyright
|
||||
* <pre>
|
||||
*
|
||||
* Copyright © 2022 Simon Ser
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
* </pre>
|
||||
*/
|
||||
struct wl_buffer;
|
||||
struct wp_single_pixel_buffer_manager_v1;
|
||||
|
||||
#ifndef WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_wp_single_pixel_buffer_manager_v1 wp_single_pixel_buffer_manager_v1
|
||||
* @section page_iface_wp_single_pixel_buffer_manager_v1_desc Description
|
||||
*
|
||||
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
* single-pixel buffers.
|
||||
* @section page_iface_wp_single_pixel_buffer_manager_v1_api API
|
||||
* See @ref iface_wp_single_pixel_buffer_manager_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_wp_single_pixel_buffer_manager_v1 The wp_single_pixel_buffer_manager_v1 interface
|
||||
*
|
||||
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
* single-pixel buffers.
|
||||
*/
|
||||
extern const struct wl_interface wp_single_pixel_buffer_manager_v1_interface;
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
* @struct wp_single_pixel_buffer_manager_v1_interface
|
||||
*/
|
||||
struct wp_single_pixel_buffer_manager_v1_interface {
|
||||
/**
|
||||
* destroy the manager
|
||||
*
|
||||
* Destroy the wp_single_pixel_buffer_manager_v1 object.
|
||||
*
|
||||
* The child objects created via this interface are unaffected.
|
||||
*/
|
||||
void (*destroy)(struct wl_client *client,
|
||||
struct wl_resource *resource);
|
||||
/**
|
||||
* create a 1×1 buffer from 32-bit RGBA values
|
||||
*
|
||||
* Create a single-pixel buffer from four 32-bit RGBA values.
|
||||
*
|
||||
* Unless specified in another protocol extension, the RGBA values
|
||||
* use pre-multiplied alpha.
|
||||
*
|
||||
* The width and height of the buffer are 1.
|
||||
*
|
||||
* The r, g, b and a arguments valid range is from UINT32_MIN (0)
|
||||
* to UINT32_MAX (0xffffffff).
|
||||
*
|
||||
* These arguments should be interpreted as a percentage, i.e. -
|
||||
* UINT32_MIN = 0% of the given color component - UINT32_MAX = 100%
|
||||
* of the given color component
|
||||
* @param r value of the buffer's red channel
|
||||
* @param g value of the buffer's green channel
|
||||
* @param b value of the buffer's blue channel
|
||||
* @param a value of the buffer's alpha channel
|
||||
*/
|
||||
void (*create_u32_rgba_buffer)(struct wl_client *client,
|
||||
struct wl_resource *resource,
|
||||
uint32_t id,
|
||||
uint32_t r,
|
||||
uint32_t g,
|
||||
uint32_t b,
|
||||
uint32_t a);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
*/
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_DESTROY_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
*/
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_CREATE_U32_RGBA_BUFFER_SINCE_VERSION 1
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="single_pixel_buffer_v1">
|
||||
<copyright>
|
||||
Copyright © 2022 Simon Ser
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="single pixel buffer factory">
|
||||
This protocol extension allows clients to create single-pixel buffers.
|
||||
|
||||
Compositors supporting this protocol extension should also support the
|
||||
viewporter protocol extension. Clients may use viewporter to scale a
|
||||
single-pixel buffer to a desired size.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="wp_single_pixel_buffer_manager_v1" version="1">
|
||||
<description summary="global factory for single-pixel buffers">
|
||||
The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
single-pixel buffers.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the manager">
|
||||
Destroy the wp_single_pixel_buffer_manager_v1 object.
|
||||
|
||||
The child objects created via this interface are unaffected.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="create_u32_rgba_buffer">
|
||||
<description summary="create a 1×1 buffer from 32-bit RGBA values">
|
||||
Create a single-pixel buffer from four 32-bit RGBA values.
|
||||
|
||||
Unless specified in another protocol extension, the RGBA values use
|
||||
pre-multiplied alpha.
|
||||
|
||||
The width and height of the buffer are 1.
|
||||
|
||||
The r, g, b and a arguments valid range is from UINT32_MIN (0)
|
||||
to UINT32_MAX (0xffffffff).
|
||||
|
||||
These arguments should be interpreted as a percentage, i.e.
|
||||
- UINT32_MIN = 0% of the given color component
|
||||
- UINT32_MAX = 100% of the given color component
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="wl_buffer"/>
|
||||
<arg name="r" type="uint" summary="value of the buffer's red channel"/>
|
||||
<arg name="g" type="uint" summary="value of the buffer's green channel"/>
|
||||
<arg name="b" type="uint" summary="value of the buffer's blue channel"/>
|
||||
<arg name="a" type="uint" summary="value of the buffer's alpha channel"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -745,6 +745,21 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
|
||||
if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */
|
||||
}
|
||||
|
||||
/* A single-pixel buffer (wl_single_pixel.c) as a 1x1 image, which the surface's viewport stretches. */
|
||||
static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resource *buffer) {
|
||||
if (s->shm_img && (vkp_image_width(s->shm_img) != 1 || vkp_image_height(s->shm_img) != 1)) {
|
||||
vkp_image_destroy(s->shm_img);
|
||||
s->shm_img = NULL;
|
||||
}
|
||||
if (!s->shm_img) s->shm_img = vkp_image_create_shm(1, 1);
|
||||
if (s->shm_img) vkp_image_upload_shm(s->shm_img, bgra, 4);
|
||||
wl_buffer_send_release(buffer);
|
||||
s->buf_w = 1;
|
||||
s->buf_h = 1;
|
||||
s->buf_alpha = bgra[3] != 0xff;
|
||||
s->has_content = s->shm_img != NULL;
|
||||
}
|
||||
|
||||
/* The window the app's performance HUD follows: the latest one at least as big as the last to
|
||||
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
|
||||
static struct surface *g_hud_surface;
|
||||
@@ -1084,6 +1099,7 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
struct wl_resource *buffer = s->pending_buffer;
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = buffer && !db ? wl_shm_buffer_get(buffer) : NULL;
|
||||
uint8_t pixel[4];
|
||||
|
||||
/* The previous content is replaced before reaching the screen. */
|
||||
feedback_discard_all(&s->feedback);
|
||||
@@ -1113,6 +1129,9 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
} else if (shm) {
|
||||
drop_dmabuf(s, 1);
|
||||
take_shm(s, shm, buffer);
|
||||
} else if (single_pixel_buffer_get(buffer, pixel)) {
|
||||
drop_dmabuf(s, 1);
|
||||
take_pixel(s, pixel, buffer);
|
||||
} else {
|
||||
drop_dmabuf(s, 1);
|
||||
s->has_content = 0;
|
||||
@@ -1661,15 +1680,24 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
static void output_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_output_interface output_impl = { .release = output_release };
|
||||
|
||||
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, NULL);
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
/* Every bound wl_output, so a resized output (a foldable opening) can send its new mode. */
|
||||
static struct wl_list g_outputs;
|
||||
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
|
||||
|
||||
static void output_send_mode(struct wl_resource *r) {
|
||||
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
|
||||
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
|
||||
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
|
||||
}
|
||||
|
||||
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, output_resource_destroy);
|
||||
wl_list_insert(&g_outputs, wl_resource_get_link(r));
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
output_send_mode(r);
|
||||
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
|
||||
wl_output_send_name(r, "DroidDeck-1");
|
||||
wl_output_send_description(r, "DroidDeck display");
|
||||
@@ -3200,6 +3228,23 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* The output changed size under a running session (a foldable opened or closed, with the
|
||||
* session following the screen). Every client that has had its first configure is told the new
|
||||
* size: gamescope's Wayland backend and the desktop's KWin both resize their output to it, and
|
||||
* the next buffers they commit fill the new panel instead of being letterboxed onto it. */
|
||||
static void output_resized(void) {
|
||||
struct surface *s;
|
||||
struct wl_resource *r;
|
||||
wl_resource_for_each(r, &g_outputs) {
|
||||
output_send_mode(r);
|
||||
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
|
||||
}
|
||||
wl_list_for_each(s, &g_surfaces, link)
|
||||
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
|
||||
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* wl event-loop callback: drain queued input events written by the Android UI thread. */
|
||||
static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
struct input_msg m;
|
||||
@@ -3212,6 +3257,7 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
|
||||
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break; /* relative motion, 1/256 px */
|
||||
case 7: deliver_touch(&m); break;
|
||||
case 8: output_resized(); break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
@@ -3242,6 +3288,14 @@ void droiddeck_wayland_send_key(int evdev, int state) {
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Called from JNI after g_output_w/h changed: the compositor thread re-sends the size. */
|
||||
void droiddeck_wayland_output_resized(void) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 8, 0, 0, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Called from JNI on every screen refresh (Choreographer). */
|
||||
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
@@ -3439,6 +3493,7 @@ static struct wl_listener g_client_created = { .notify = on_client_created };
|
||||
int droiddeck_wayland_run(void) {
|
||||
wl_list_init(&g_surfaces);
|
||||
wl_list_init(&g_toplevels);
|
||||
wl_list_init(&g_outputs);
|
||||
wl_list_init(&g_constraints);
|
||||
wl_list_init(&g_relative_pointers);
|
||||
open_session_log();
|
||||
|
||||
@@ -122,4 +122,5 @@ void droiddeck_ext_init(struct wl_display *display) {
|
||||
clipboard_init(display);
|
||||
text_input_init(display);
|
||||
toplevel_icon_init(display);
|
||||
single_pixel_init(display);
|
||||
}
|
||||
@@ -48,3 +48,6 @@ void text_input_host_commit(const char *utf8, size_t len);
|
||||
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end);
|
||||
void text_input_host_delete(int before, int after);
|
||||
void toplevel_icon_init(struct wl_display *display);
|
||||
void single_pixel_init(struct wl_display *display);
|
||||
/* A wp_single_pixel_buffer_manager_v1 buffer: 1 and its pixel (B, G, R, A, premultiplied), else 0. */
|
||||
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]);
|
||||
@@ -160,6 +160,11 @@ static const char *vk_result_name(VkResult r);
|
||||
static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static ANativeWindow *g_req_window;
|
||||
static int g_req_pending;
|
||||
/* A detach was asked for since the compositor thread last looked. A rebind (null, then the same
|
||||
* window) to rebuild the swapchain at a new size can land in full before the thread gets to it,
|
||||
* and would otherwise read as the same window handed over again - nothing rebuilt, and the old
|
||||
* size's buffers scaled onto the rotated or refolded panel. */
|
||||
static int g_req_detached;
|
||||
|
||||
/* Scale mode + alignment (app values, see vk_present.h); written from any thread, read per frame. */
|
||||
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
|
||||
@@ -252,6 +257,7 @@ void vk_present_set_window(ANativeWindow *window) {
|
||||
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
|
||||
g_req_window = window;
|
||||
g_req_pending = 1;
|
||||
if (!window) g_req_detached = 1;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (superseded) ANativeWindow_release(superseded);
|
||||
}
|
||||
@@ -261,13 +267,20 @@ int vkp_apply_window_request(void) {
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
|
||||
w = g_req_window;
|
||||
const int detached = g_req_detached;
|
||||
g_req_window = NULL;
|
||||
g_req_pending = 0;
|
||||
g_req_detached = 0;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (w == g_window) {
|
||||
/* The same window handed over again (ANativeWindow_fromSurface adds a reference each time). */
|
||||
if (w) ANativeWindow_release(w);
|
||||
return 0;
|
||||
if (!w || !detached) return 0;
|
||||
/* ... after a detach: a rebind for a resized surface. Rebuilt at its size on the next frame. */
|
||||
sc_layer_window_gone();
|
||||
destroy_swapchain();
|
||||
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
|
||||
return 1;
|
||||
}
|
||||
sc_layer_window_gone(); /* the layer (if any) belongs to the old window */
|
||||
destroy_swapchain(); /* recreated against the new window on the next frame */
|
||||
@@ -1516,6 +1529,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
const struct vkp_hdr_frame *hf, int *how) {
|
||||
if (how) *how = 0;
|
||||
vkp_apply_window_request();
|
||||
check_surface_changed(); /* a rotation or a fold the rebind did not cover: rebuild at the new size */
|
||||
if (g_dev_state == -2) return -1;
|
||||
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
|
||||
@@ -2099,6 +2113,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
|
||||
if (g_pass.active) vkp_pass_abort();
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
check_surface_changed(); /* as in render_impl: the effects pass maps onto the output's size */
|
||||
if (!g_swapchain && swap_init() != 0) return -1; /* the mapping is in output pixels */
|
||||
update_map(scene_w, scene_h);
|
||||
if (!g_map.valid) return -1;
|
||||
|
||||
@@ -30,6 +30,7 @@ extern int droiddeck_cursor_serial(void);
|
||||
extern void droiddeck_wayland_send_key(int evdev, int state);
|
||||
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_wayland_output_resized(void);
|
||||
extern volatile int g_fps_limit;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
@@ -401,6 +402,17 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv
|
||||
g_output_h = h > 0 ? h : 0;
|
||||
}
|
||||
|
||||
/* The output's size changed while the compositor runs (a foldable opening or closing with the
|
||||
* session following the screen): store it and have the compositor thread tell the clients. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv *env, jclass clazz, jint w, jint h) {
|
||||
(void)env; (void)clazz;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
g_output_w = w;
|
||||
g_output_h = h;
|
||||
droiddeck_wayland_output_resized();
|
||||
}
|
||||
|
||||
/* Fullscreen mode + screen alignment (Container.FULLSCREEN_* / ALIGN_* values): how the scene is
|
||||
* fitted onto the output. Any thread, any time; the next frame uses it. */
|
||||
JNIEXPORT void JNICALL
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* wp_single_pixel_buffer_manager_v1 (version 1): a wl_buffer of one pixel of one colour, which a
|
||||
* client stretches over a surface with wp_viewporter. KWin's Wayland backend - the desktop's KWin
|
||||
* nested in this compositor - refuses to start without it. The surface takes the pixel as a 1x1
|
||||
* image, the way it takes a wl_shm buffer (compositor.c, take_pixel).
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include "droiddeck_ext.h"
|
||||
#include "single-pixel-buffer-v1-server-protocol.h"
|
||||
|
||||
struct single_pixel {
|
||||
uint8_t bgra[4]; /* premultiplied, as wl_shm's ARGB8888 lies in memory */
|
||||
};
|
||||
|
||||
static void buffer_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_buffer_interface buffer_impl = { .destroy = buffer_destroy };
|
||||
|
||||
static void buffer_free(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
|
||||
|
||||
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]) {
|
||||
if (!buffer || !wl_resource_instance_of(buffer, &wl_buffer_interface, &buffer_impl)) return 0;
|
||||
struct single_pixel *p = wl_resource_get_user_data(buffer);
|
||||
for (int i = 0; i < 4; i++) bgra[i] = p->bgra[i];
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void mgr_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void mgr_create_buffer(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
uint32_t red, uint32_t green, uint32_t blue, uint32_t alpha) {
|
||||
struct single_pixel *p = calloc(1, sizeof(*p));
|
||||
struct wl_resource *buffer = p ? wl_resource_create(c, &wl_buffer_interface, 1, id) : NULL;
|
||||
if (!buffer) { free(p); wl_client_post_no_memory(c); return; }
|
||||
/* The protocol's values span the whole u32 range; the top byte is the 8-bit channel. */
|
||||
p->bgra[0] = blue >> 24;
|
||||
p->bgra[1] = green >> 24;
|
||||
p->bgra[2] = red >> 24;
|
||||
p->bgra[3] = alpha >> 24;
|
||||
wl_resource_set_implementation(buffer, &buffer_impl, p, buffer_free);
|
||||
}
|
||||
static const struct wp_single_pixel_buffer_manager_v1_interface mgr_impl = {
|
||||
.destroy = mgr_destroy, .create_u32_rgba_buffer = mgr_create_buffer,
|
||||
};
|
||||
static void bind_manager(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wp_single_pixel_buffer_manager_v1_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &mgr_impl, NULL, NULL);
|
||||
}
|
||||
|
||||
void single_pixel_init(struct wl_display *display) {
|
||||
wl_global_create(display, &wp_single_pixel_buffer_manager_v1_interface, 1, NULL, bind_manager);
|
||||
}
|
||||
@@ -22,12 +22,15 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs, so the first session already runs in Deck mode.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
com.droiddeck.launcher.frontend.GameFileSync.start(this)
|
||||
// The game stores the Stores section can talk to; the native engine loads only when one needs it.
|
||||
com.droiddeck.launcher.stores.StoresState.init(this)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class HomeActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
routeHome()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.io.File
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -17,6 +18,7 @@ import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -35,6 +37,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.ui.PackageRow
|
||||
import com.droiddeck.launcher.ui.exposeTestTags
|
||||
import com.droiddeck.launcher.session.OfflineMode
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
@@ -118,7 +121,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
@@ -138,6 +140,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var pkgStage by mutableStateOf<String?>(null)
|
||||
private var pkgPercent by mutableIntStateOf(-1)
|
||||
private var desktopInstalled by mutableStateOf(false)
|
||||
private var desktopPresent by mutableStateOf(false)
|
||||
private var offlineAccount by mutableStateOf<String?>(null)
|
||||
private var offline by mutableStateOf(false)
|
||||
private var showPerformance by mutableStateOf(false)
|
||||
@@ -159,11 +162,17 @@ class MainActivity : ComponentActivity() {
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(true)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var gameStoresEnabled by mutableStateOf(false)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
@@ -295,29 +304,34 @@ class MainActivity : ComponentActivity() {
|
||||
GameSaves.savesDir().mkdirs()
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
|
||||
}
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
/** The Games tab's +: the picked .exe becomes a game (or selects / switches the one it belongs to). */
|
||||
private fun addGameExe(path: String) {
|
||||
val exe = java.io.File(path)
|
||||
Thread({
|
||||
val result = try { com.droiddeck.launcher.frontend.AddedGames.addExe(this, exe) } catch (e: Exception) {
|
||||
Log.w(TAG, "add game: ${e.message}"); com.droiddeck.launcher.frontend.AddedGames.AddResult.NotFound
|
||||
}
|
||||
val game = when (result) {
|
||||
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Added -> result.game
|
||||
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Existing -> result.game
|
||||
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Switched -> result.game
|
||||
else -> null
|
||||
}
|
||||
ui.post {
|
||||
if (game == null) {
|
||||
if (result is com.droiddeck.launcher.frontend.AddedGames.AddResult.Unreachable)
|
||||
android.widget.Toast.makeText(this, R.string.games_add_unreachable, android.widget.Toast.LENGTH_LONG).show()
|
||||
return@post
|
||||
}
|
||||
updateAddedGames(listOf(game.folder.path), then = { fetchAddedGameArt() })
|
||||
navRequest = "app:${game.steamAppId ?: game.appId.toInt()}"
|
||||
}
|
||||
}, "add-game-exe").start()
|
||||
}
|
||||
private var pendingAddedGame: String? = null
|
||||
private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val folder = pendingAddedGame ?: return@registerForActivityResult
|
||||
pendingAddedGame = null
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGameExe(this, folder, path)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var addedGamesDirs by mutableStateOf<List<String>>(emptyList())
|
||||
private var addedGamesArt by mutableStateOf(true)
|
||||
@Volatile private var artFetchRunning = false
|
||||
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
|
||||
/** The user entered a SteamGridDB API key of their own. */
|
||||
private var sgdbUserKey by mutableStateOf(false)
|
||||
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setRomsDir(this, path)
|
||||
@@ -329,7 +343,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
@@ -343,7 +356,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var renderer by mutableStateOf("vulkan")
|
||||
private var gameStorage by mutableStateOf("")
|
||||
private var storageDiagnostics by mutableStateOf(false)
|
||||
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
|
||||
@@ -352,9 +364,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
private var romsDir by mutableStateOf<String?>(null)
|
||||
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
|
||||
@Volatile private var componentSweepDone = false
|
||||
@Volatile private var componentSweepSession: String? = null
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
private var agentCommands by mutableStateOf(false)
|
||||
private var showRoms by mutableStateOf(false)
|
||||
private var homeAppSelected by mutableStateOf(false)
|
||||
private var homeScreenEnabled by mutableStateOf(false)
|
||||
@@ -436,6 +451,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun readGameIntent(request: Intent) {
|
||||
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
|
||||
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
|
||||
if (shortcutPicker) {
|
||||
// The shortcut picker must only offer games from the scan started for this request.
|
||||
@@ -519,14 +535,20 @@ class MainActivity : ComponentActivity() {
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
storesShowMature = SessionPrefs.storesShowMature(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
@@ -544,9 +566,9 @@ class MainActivity : ComponentActivity() {
|
||||
shortcutLibraryScanning = shortcutLibraryScanning,
|
||||
gameSyncFolder = gameSyncFolder,
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
desktopInstalled = desktopInstalled,
|
||||
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
@@ -572,23 +594,54 @@ class MainActivity : ComponentActivity() {
|
||||
updates = updates.state(),
|
||||
phantomProcessStatus = phantomProcessStatus,
|
||||
showPhantomGate = showPhantomGate,
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
navigate = navRequest,
|
||||
addedGamesArt = addedGamesArt,
|
||||
sgdbUserKey = sgdbUserKey,
|
||||
storesShowMature = storesShowMature,
|
||||
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
// Steam's desktop client as a window on the desktop: under gamescope the
|
||||
// client puts itself into Big Picture whatever it is started with.
|
||||
// The client's desktop UI, with gamescope's Deck integration disabled.
|
||||
onPlayDesktopUi = {
|
||||
startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
else {
|
||||
// The launch takes the Windows components already on; the game's list is
|
||||
// looked at again in the background once the session is over.
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
||||
}
|
||||
},
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
onAddGameExe = { path -> addGameExe(path) },
|
||||
onAddedGamesChanged = { folders ->
|
||||
// New games want art; an edit does not.
|
||||
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
|
||||
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
|
||||
},
|
||||
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onSgdbKey = { key ->
|
||||
Thread({
|
||||
com.droiddeck.launcher.frontend.SteamGridDb.setUserKey(this, key)
|
||||
ui.post { sgdbUserKey = key.isNotBlank(); refreshAddedGames() }
|
||||
}, "sgdb-key").start()
|
||||
},
|
||||
onStopGameFileSync = {
|
||||
Thread({
|
||||
GameFileSync.disable(this)
|
||||
@@ -631,6 +684,7 @@ class MainActivity : ComponentActivity() {
|
||||
// The activity re-attaches to the session that is running; nothing restarts.
|
||||
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
|
||||
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
|
||||
onRemoveDesktop = { removeDesktop() },
|
||||
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
@@ -658,6 +712,10 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
},
|
||||
onAgentCommands = {
|
||||
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -691,12 +749,23 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
},
|
||||
onOrientation = { value ->
|
||||
SessionPrefs.setOrientation(this, value)
|
||||
orientation = value
|
||||
requestedOrientation = value
|
||||
},
|
||||
onLauncherFullscreen = { on ->
|
||||
SessionPrefs.setLauncherFullscreen(this, on)
|
||||
launcherFullscreen = on
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
|
||||
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
|
||||
onLibraryChanged = { refreshAddedGames(); refresh() },
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
@@ -774,6 +843,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
|
||||
@@ -838,8 +908,13 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setMicAsked(this)
|
||||
}
|
||||
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
|
||||
// A store sign-in an earlier build left in plain text is sealed with the Keystore key.
|
||||
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
|
||||
// A session folder left without its ending - the process was killed - gets it now.
|
||||
if (!SessionState.running) Thread({
|
||||
com.droiddeck.launcher.session.LogMigration.run(this)
|
||||
com.droiddeck.launcher.frontend.GamesFolderMigration.run(this)
|
||||
SessionLogShare.clear(this)
|
||||
SessionArtifacts.finishAbandoned(this)
|
||||
SessionArtifacts.scrubOlder(this)
|
||||
SessionArtifacts.prune(this)
|
||||
@@ -880,10 +955,20 @@ class MainActivity : ComponentActivity() {
|
||||
} else {
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = if (launcherFullscreen) {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
} else {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
@@ -892,12 +977,26 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
// Compose throws when its focus tree has lost the focused node; one key is dropped
|
||||
// instead of the app.
|
||||
return try {
|
||||
super.dispatchKeyEvent(event)
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e.message?.contains("active focus target") != true) throw e
|
||||
android.util.Log.w("MainActivity", "key ${event.keyCode} dropped: ${e.message}")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once. A store
|
||||
// install finishing while this screen is up rebuilds the Games list through the listener.
|
||||
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
// Back from a session stopped behind a flood: open on its blue, before the first frame.
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
|
||||
@@ -955,6 +1054,8 @@ class MainActivity : ComponentActivity() {
|
||||
unregisterReceiver(wifiLocationReceiver)
|
||||
unregisterReceiver(mediaReceiver)
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
// An install finishing while this screen is away is picked up by onResume's refresh.
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = null
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
// The session covers the page by now; coming back finds it as it was.
|
||||
flood = null
|
||||
@@ -1131,6 +1232,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun refreshPackages() {
|
||||
packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) }
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
}
|
||||
|
||||
private fun launchProgram(path: String) {
|
||||
@@ -1184,21 +1286,19 @@ class MainActivity : ComponentActivity() {
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
clientDirectAudio = clientDirectAudio,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
storageOptions = storageOptions,
|
||||
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
forceSsbs = forceSsbs,
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
@@ -1208,9 +1308,6 @@ class MainActivity : ComponentActivity() {
|
||||
wifiDiscoveryLocation = wifiDiscoveryLocation,
|
||||
wifiDiscoveryAsked = wifiDiscoveryAsked,
|
||||
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
@@ -1246,13 +1343,13 @@ class MainActivity : ComponentActivity() {
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1263,7 +1360,6 @@ class MainActivity : ComponentActivity() {
|
||||
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 2)
|
||||
}
|
||||
},
|
||||
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
|
||||
onGameStorage = { path, label -> setGameStorage(path, label) },
|
||||
onPickGameStorageFolder = {
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
|
||||
@@ -1280,27 +1376,13 @@ class MainActivity : ComponentActivity() {
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamDeckMode = { on ->
|
||||
SessionPrefs.setSteamDeckMode(this, on)
|
||||
steamDeckMode = on
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
},
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
|
||||
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
|
||||
onPickAddedGameExe = { folder ->
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
|
||||
},
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
@@ -1355,18 +1437,44 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */
|
||||
/** The added games for the session settings: a folder walk, so off the main thread. */
|
||||
private fun refreshAddedGames() {
|
||||
/**
|
||||
* The Games tab after these added games were added or edited: each read alone and put in
|
||||
* place of its old entry (or added), no library-wide scan. [then] runs after, on the main thread.
|
||||
*/
|
||||
private fun updateAddedGames(folders: List<String>, then: () -> Unit = {}) {
|
||||
if (folders.isEmpty()) return
|
||||
Thread({
|
||||
val games = scanAddedGames()
|
||||
ui.post { addedGames = games; fetchAddedGameArt() }
|
||||
}, "added-games").start()
|
||||
val fresh = folders.mapNotNull { f ->
|
||||
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
|
||||
}
|
||||
// An added or edited game's Windows components, from its source's list.
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(this, it) }
|
||||
ui.post {
|
||||
val paths = folders.toSet()
|
||||
steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId }
|
||||
then()
|
||||
}
|
||||
}, "added-games-update").start()
|
||||
}
|
||||
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
/** An added game removed: off the Games tab now; the rest (cache, Steam, listing) off the main thread. */
|
||||
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
|
||||
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
|
||||
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
|
||||
}
|
||||
|
||||
/** After the added games changed: art that is missing is fetched (off the main thread). */
|
||||
private fun refreshAddedGames() {
|
||||
sgdbKeyKnown()
|
||||
fetchAddedGameArt()
|
||||
}
|
||||
|
||||
/** Whether the user has a SteamGridDB key of their own, for Setup; read off the main thread. */
|
||||
private fun sgdbKeyKnown() {
|
||||
Thread({
|
||||
val set = com.droiddeck.launcher.frontend.SteamGridDb.userKey(this).isNotEmpty()
|
||||
ui.post { sgdbUserKey = set }
|
||||
}, "sgdb-key-state").start()
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
@@ -1386,9 +1494,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
@@ -1399,13 +1509,10 @@ class MainActivity : ComponentActivity() {
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
@@ -1417,11 +1524,9 @@ class MainActivity : ComponentActivity() {
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
renderer = SessionPrefs.desktopRenderer(this)
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
settingsMode = mode
|
||||
@@ -1429,12 +1534,10 @@ class MainActivity : ComponentActivity() {
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val games = scanAddedGames()
|
||||
val storage = GameStorage.options(this)
|
||||
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
|
||||
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
|
||||
ui.post {
|
||||
addedGames = games
|
||||
storageOptions = storage
|
||||
if (mode == SessionService.MODE_STEAM) {
|
||||
decky.deckyInstalled = deckyInstalled
|
||||
@@ -1464,7 +1567,6 @@ class MainActivity : ComponentActivity() {
|
||||
clientOverride = SessionPrefs.clientCpusOverride(this)
|
||||
glThread = SessionPrefs.glThread(this)
|
||||
noGlError = SessionPrefs.noGlError(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() }
|
||||
gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() }
|
||||
tuSysmem = SessionPrefs.tuSysmem(this)
|
||||
@@ -1483,7 +1585,9 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refresh() {
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
offlineAccount = OfflineMode.account(this)
|
||||
offline = OfflineMode.enabled(this)
|
||||
installed = LinuxRuntimeInstaller.installedVersion(this)
|
||||
@@ -1492,6 +1596,7 @@ class MainActivity : ComponentActivity() {
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
runningLabel = if (SessionState.running) when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
|
||||
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
|
||||
@@ -1502,13 +1607,39 @@ class MainActivity : ComponentActivity() {
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// One update with the whole list: the wall places games by their position in it, so a
|
||||
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
|
||||
// covers the wait.
|
||||
// Steam's own games first, from their manifests alone (quick), beside the added games
|
||||
// already listed: a game Steam installed during the session shows as the session ends,
|
||||
// without waiting for the added games' walk below. Its Windows components' list is
|
||||
// queued then too, on the low-priority worker, so its page is usually ready.
|
||||
if (ready) {
|
||||
val quick = runCatching { Library.steamGames(this) }.getOrNull()
|
||||
if (quick != null) ui.post {
|
||||
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
|
||||
val before = steamGames.map { it.gameId }.toSet()
|
||||
val fresh = quick.filter { it.gameId !in before }
|
||||
if (fresh.isNotEmpty()) {
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then one update with the whole list: the wall places games by their position in it,
|
||||
// so a partial list first would shuffle every capsule when the rest arrived.
|
||||
// LibraryCache covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
// Once per app start: the Steam games' Windows components from Steam's lists.
|
||||
// Queued only, after the launcher has been up a little; the worker takes them one at a time.
|
||||
// And again after each session (a game installed in it, files changed).
|
||||
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
|
||||
if (ready && (!componentSweepDone || endedSession != null)) {
|
||||
componentSweepDone = true
|
||||
endedSession?.let { componentSweepSession = it }
|
||||
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
|
||||
}
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
@@ -1595,6 +1726,15 @@ class MainActivity : ComponentActivity() {
|
||||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
|
||||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
|
||||
|
||||
private fun removeDesktop() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val operation = LinuxRuntimeInstaller.beginMaintenance(this, getString(R.string.desktop_removing)) ?: return
|
||||
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
|
||||
followRuntimeOperation { listener ->
|
||||
operation.run(listener) { progress -> DesktopCatalog.removeDesktop(this, progress) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeRuntime() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
|
||||
@@ -1617,7 +1757,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
|
||||
busy = true
|
||||
failed = false
|
||||
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
|
||||
stage = getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
|
||||
percent = -1
|
||||
Thread({
|
||||
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.Display
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
@@ -45,11 +46,13 @@ import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.InputSpace
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SessionClipboard
|
||||
import com.droiddeck.launcher.input.OnScreenControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
@@ -108,6 +111,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
|
||||
private var pipUi by mutableStateOf(false)
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
/** Unfolded controls: the setting, and the Deck controls under the game while they show. */
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private val guestKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var surfaceView: SurfaceView
|
||||
private lateinit var sessionOverlay: ComposeView
|
||||
@@ -118,7 +125,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } }
|
||||
|
||||
/**
|
||||
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
|
||||
@@ -131,6 +138,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
// Every pad already connected is a player from the start, before its first press.
|
||||
padBridge?.claimConnected()
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
@@ -141,9 +150,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
|
||||
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
|
||||
else padMotion?.stop()
|
||||
}
|
||||
private var steamController: SteamControllerBle? = null
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
|
||||
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
|
||||
private var steamTouchAvailable by mutableStateOf(false)
|
||||
private val steamTouchRetry = Runnable { updateOnScreenControls() }
|
||||
/** While Steam's touch controls are up: how the game's touch config says touches outside them
|
||||
* act (Steam Link's input and mouse modes), in place of the drawer's touch mode. */
|
||||
private var steamTouchOptions: com.droiddeck.launcher.input.SteamTouchConfig.Options? = null
|
||||
/** The touch mode touches are routed by: the drawer's, or the game's while Steam's controls are up. */
|
||||
private var routedTouchMode = SessionPrefs.TOUCH_AUTO
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private val sessionClipboard by lazy {
|
||||
@@ -231,6 +252,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
/** What the pad is to the Steam client (SessionPrefs.CONTROLLER_*), switched from the drawer. */
|
||||
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
@@ -289,11 +312,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.claimConnected()
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
padBridge?.onDeviceRemoved(deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
@@ -304,7 +329,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
runtimeRemovalBlocked = true
|
||||
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
@@ -335,6 +362,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
|
||||
if (!SessionState.running) SessionState.followScreen = followChoice()
|
||||
applyFollowOrientation(resources.configuration)
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
// PulseAudio are media playback); they are never forwarded to the guest.
|
||||
@@ -356,12 +385,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
sessionRoot = root
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
// The window's size is only known once laid out, and changes as the phone folds or turns.
|
||||
root.addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
|
||||
if (r - l != or - ol || b - t != ob - ot) uiHandler.post { applyUnfoldedControls() }
|
||||
}
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -553,12 +592,30 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
// Only where the session set the pad up to be switched (SessionService: deckPad).
|
||||
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
|
||||
onPadType = { id ->
|
||||
SessionPrefs.setSteamController(this@SessionActivity, id)
|
||||
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
|
||||
padType = id
|
||||
},
|
||||
backActionsInverted = backActionsInverted,
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
|
||||
onOsc = { v ->
|
||||
SessionPrefs.setOscMode(this@SessionActivity, v)
|
||||
readPrefs()
|
||||
updateOnScreenControls()
|
||||
if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null)
|
||||
Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({
|
||||
drawerOpen = false
|
||||
steamTouchControls?.startEditing()
|
||||
}) else null,
|
||||
onSuspendPolicy = { policy ->
|
||||
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
|
||||
suspendPolicy = policy
|
||||
@@ -595,6 +652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
pipAutoEnter = on
|
||||
pip.refresh(true)
|
||||
},
|
||||
unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null,
|
||||
onUnfoldedControls = ::chooseUnfoldedControls,
|
||||
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
|
||||
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
@@ -741,9 +800,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun loadingTitle(): String = when (loadingMode()) {
|
||||
SessionService.MODE_DESKTOP ->
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
|
||||
else getString(R.string.session_starting_desktop)
|
||||
// Also when the desktop was asked for with Steam (Big Picture's Switch to Desktop): the client
|
||||
// does not open on it.
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_starting_desktop)
|
||||
SessionService.MODE_RUN -> {
|
||||
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
|
||||
@@ -775,6 +834,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
@@ -790,15 +850,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
}
|
||||
}
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
|
||||
}
|
||||
touchMode = nextTouchMode
|
||||
routedTouchMode = effectiveTouchMode()
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
padType = SessionPrefs.steamController(this)
|
||||
}
|
||||
|
||||
// ── Compositor ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -925,8 +987,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Null when the desktop package is in, else the loading screen's closing line. */
|
||||
private fun installDesktop(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
|
||||
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
|
||||
?: return getString(R.string.session_desktop_catalog_unreachable)
|
||||
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.desktopEntry(this)
|
||||
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
|
||||
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
|
||||
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
|
||||
@@ -960,7 +1021,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// sized once when the session starts and cannot change. A foldable recreates the Surface
|
||||
// on the other panel, and recomputing the size there told the compositor a 21:9 buffer
|
||||
// was 16:9 - the picture came back squashed sideways. While a session runs, keep its size.
|
||||
val size = if (SessionState.running) SessionState.outputSize else outputSize()
|
||||
// Follow screen is the exception: there the compositor tells gamescope the new size, so the
|
||||
// session takes the shape of the Surface it now has.
|
||||
val previous = SessionState.outputSize
|
||||
val live = if (SessionState.followScreen && !isInPictureInPictureMode)
|
||||
com.droiddeck.launcher.session.SessionDisplay.followSize(holder.surfaceFrame.width(), holder.surfaceFrame.height())
|
||||
else null
|
||||
val size = live ?: if (SessionState.running) previous else outputSize()
|
||||
SessionState.outputSize = size
|
||||
if (!SessionState.running) SessionState.refreshHz = refreshHz()
|
||||
// How far the panel enlarges the session, for texture sharpness "Auto" (TextureFiltering):
|
||||
@@ -1019,6 +1086,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
|
||||
)
|
||||
if (SessionState.running && size != previous) {
|
||||
Log.i(TAG, "follow screen: ${previous.first}x${previous.second} -> ${size.first}x${size.second} on a new surface")
|
||||
WaylandCompositor.nativeResizeOutput(size.first, size.second)
|
||||
}
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
// running - NOT only when the compositor was just started: the compositor lives for the
|
||||
@@ -1047,9 +1118,52 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (resized) {
|
||||
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
|
||||
CompositorHost.resize(holder.surface) { applyFrameGen() }
|
||||
followWindow(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow screen: the session's display takes the window's new size (a Fold opening onto its
|
||||
* inner panel, or closing onto the cover), so the picture fills the panel instead of being
|
||||
* letterboxed onto it. Not in picture-in-picture: that window is a preview of the session,
|
||||
* and resizing the guest to it would leave Steam laid out for a thumbnail.
|
||||
*/
|
||||
private fun followWindow(width: Int, height: Int) {
|
||||
if (!SessionState.followScreen || isInPictureInPictureMode || !CompositorHost.isStarted) return
|
||||
val size = com.droiddeck.launcher.session.SessionDisplay.followSize(width, height) ?: return
|
||||
if (size == SessionState.outputSize) return
|
||||
Log.i(TAG, "follow screen: ${SessionState.outputSize.first}x${SessionState.outputSize.second} -> ${size.first}x${size.second}")
|
||||
SessionState.outputSize = size
|
||||
val panel = panelBounds()
|
||||
SessionState.upscaleRatio = maxOf(panel.width(), panel.height()).toFloat() / maxOf(size.first, size.second).coerceAtLeast(1)
|
||||
WaylandCompositor.nativeResizeOutput(size.first, size.second)
|
||||
}
|
||||
|
||||
/** Whether this session's resolution is Follow screen; decided once, when it starts. */
|
||||
private fun followChoice(): Boolean {
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
|
||||
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow screen on a large panel (a Fold's inner screen) may turn with the device: a window
|
||||
* locked to landscape on a squarer panel is letterboxed by the system itself, which no output
|
||||
* size can undo. A phone-sized panel (the cover screen) stays landscape as before.
|
||||
*/
|
||||
private fun applyFollowOrientation(config: android.content.res.Configuration) {
|
||||
if (!SessionState.followScreen) return
|
||||
val wanted = if (config.smallestScreenWidthDp >= 600) android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
|
||||
else android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
if (requestedOrientation != wanted) requestedOrientation = wanted
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
applyFollowOrientation(newConfig)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
surfaceW = 0
|
||||
surfaceH = 0
|
||||
@@ -1275,7 +1389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (pipUi) return true
|
||||
val fromController = event.device != null && PadBridge.isFromController(event.device)
|
||||
val fromController = PadBridge.isControllerKeyEvent(event)
|
||||
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
@@ -1327,12 +1441,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event)
|
||||
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
|
||||
// activity, which opens the drawer.
|
||||
val fromPad = event.device != null && PadBridge.isFromController(event.device)
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
if (down || event.action == KeyEvent.ACTION_UP) {
|
||||
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
|
||||
@@ -1370,7 +1483,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
|
||||
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
|
||||
if (!drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
|
||||
@@ -1381,7 +1494,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event)
|
||||
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if (padBridge?.onMotionEvent(event) == true) return true
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
@@ -1485,18 +1598,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun movePointer(x: Float, y: Float) {
|
||||
val width = surfaceView.width.takeIf { it > 0 } ?: return
|
||||
val height = surfaceView.height.takeIf { it > 0 } ?: return
|
||||
val out = SessionState.outputSize
|
||||
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
|
||||
val drawnW = out.first * scale
|
||||
val drawnH = out.second * scale
|
||||
val left = (width - drawnW) / 2f
|
||||
val top = (height - drawnH) / 2f
|
||||
val px = ((x - left) / drawnW * 1920f).toInt().coerceIn(0, 1919)
|
||||
val py = ((y - top) / drawnH * 1080f).toInt().coerceIn(0, 1079)
|
||||
WaylandCompositor.nativeSendPointer(1, px, py)
|
||||
showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH))
|
||||
val rect = drawnRect() ?: return
|
||||
val cx = x.coerceIn(rect.left, rect.right)
|
||||
val cy = y.coerceIn(rect.top, rect.bottom)
|
||||
WaylandCompositor.nativeSendPointer(1, InputSpace.x(cx, surfaceView.width), InputSpace.y(cy, surfaceView.height))
|
||||
showCursor(cx, cy)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1612,17 +1718,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
},
|
||||
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
|
||||
onSteamMenu = {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) {
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
},
|
||||
onQam = {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
|
||||
},
|
||||
onSteamMenu = ::tapSteamButton,
|
||||
onQam = ::tapQam,
|
||||
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
|
||||
)
|
||||
secondScreenPresentation = presentation
|
||||
@@ -1639,14 +1736,73 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun tapSteamButton() {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) {
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tapQam() {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
|
||||
}
|
||||
|
||||
private fun chooseUnfoldedControls(on: Boolean) {
|
||||
SessionPrefs.setUnfoldedControls(this, on)
|
||||
unfoldedControls = on
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolded controls: on a foldable's inner screen, the game above and the Steam Deck controller's
|
||||
* trackpads and back grips below ([com.droiddeck.launcher.ui.DeckControlsPanel], as a second
|
||||
* screen shows them, plus a whole gamepad). The on-screen pad is hidden meanwhile. The surface shrinks to the top part, so a Follow screen session resizes
|
||||
* to it and any other is fitted into it. Folded onto the cover screen - a phone-sized panel -
|
||||
* or in picture-in-picture, the game is full screen again.
|
||||
*/
|
||||
private fun applyUnfoldedControls() {
|
||||
if (!::sessionRoot.isInitialized) return
|
||||
val width = sessionRoot.width
|
||||
val height = sessionRoot.height
|
||||
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
|
||||
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
|
||||
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
|
||||
else FrameLayout.LayoutParams.MATCH_PARENT
|
||||
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
|
||||
if (params.height != gameHeight) {
|
||||
surfaceView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, gameHeight, Gravity.TOP)
|
||||
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
|
||||
else "unfolded controls: game full screen")
|
||||
}
|
||||
var panel = unfoldedPanel
|
||||
if (!wanted) {
|
||||
if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() }
|
||||
return
|
||||
}
|
||||
if (panel == null) {
|
||||
panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) },
|
||||
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge)
|
||||
// Just above the surface: the on-screen pad, keyboard and drawer still draw over it.
|
||||
sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1)
|
||||
unfoldedPanel = panel
|
||||
updateOnScreenControls()
|
||||
}
|
||||
panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
|
||||
}
|
||||
|
||||
private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
val nx = (x / width).coerceIn(0f, 1f)
|
||||
val ny = (y / height).coerceIn(0f, 1f)
|
||||
WaylandCompositor.nativeSendPointer(1, (nx * 1919f).toInt(), (ny * 1079f).toInt())
|
||||
drawnRect()?.let { rect ->
|
||||
showCursor(rect.left + nx * rect.width(), rect.top + ny * rect.height())
|
||||
}
|
||||
val rect = drawnRect() ?: return
|
||||
val px = rect.left + nx * rect.width()
|
||||
val py = rect.top + ny * rect.height()
|
||||
WaylandCompositor.nativeSendPointer(1, InputSpace.x(px, surfaceView.width), InputSpace.y(py, surfaceView.height))
|
||||
showCursor(px, py)
|
||||
}
|
||||
|
||||
private fun closeSecondScreen(reset: Boolean) {
|
||||
@@ -1658,8 +1814,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** The touch mode in force: the game's own while Steam's touch controls are shown. */
|
||||
private fun effectiveTouchMode(): String {
|
||||
val options = steamTouchOptions
|
||||
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
|
||||
return when {
|
||||
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
|
||||
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
|
||||
else -> SessionPrefs.TOUCH_DIRECT
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTouchRouting() {
|
||||
val next = effectiveTouchMode()
|
||||
if (::touchpad.isInitialized) {
|
||||
val speed = steamTouchOptions?.trackpadSensitivity?.takeIf { steamTouchControls?.visibility == View.VISIBLE } ?: 1f
|
||||
touchpad.sensitivity = 1.15f * speed
|
||||
}
|
||||
if (next == routedTouchMode) return
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
routedTouchMode = next
|
||||
}
|
||||
|
||||
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
|
||||
private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) {
|
||||
private fun usingTouchpad(): Boolean = when (routedTouchMode) {
|
||||
SessionPrefs.TOUCH_PAD -> true
|
||||
SessionPrefs.TOUCH_DIRECT -> false
|
||||
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
|
||||
@@ -1681,7 +1861,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
// Child views get the event first, so menus, keyboards and the on-screen pad still work.
|
||||
if (touchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (usingTouchpad()) {
|
||||
val rect = drawnRect() ?: return false
|
||||
if (touchpad.bounds != rect) {
|
||||
@@ -1693,9 +1873,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
val rect = drawnRect() ?: return false
|
||||
fun sendTouch(action: Int, index: Int) {
|
||||
val x = ((event.getX(index) - rect.left) / rect.width()).coerceIn(0f, 1f)
|
||||
val y = ((event.getY(index) - rect.top) / rect.height()).coerceIn(0f, 1f)
|
||||
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), (x * 1919f).toInt(), (y * 1079f).toInt())
|
||||
val x = event.getX(index).coerceIn(rect.left, rect.right)
|
||||
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
|
||||
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index),
|
||||
InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height))
|
||||
}
|
||||
when (event.actionMasked) {
|
||||
// A first finger starts a new gesture, and Android has ended every earlier one: whatever
|
||||
@@ -1799,30 +1980,97 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
/** Steam's touch controls, once the session has made the device (SessionService). */
|
||||
private fun steamTouch(): com.droiddeck.launcher.input.SteamTouchControls? {
|
||||
val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return null
|
||||
steamTouchControls?.let { view ->
|
||||
if (view.device === device) return view
|
||||
// A new session made a new device: the view of the old one goes.
|
||||
view.releaseAll()
|
||||
(view.parent as? android.view.ViewGroup)?.removeView(view)
|
||||
steamTouchControls = null
|
||||
}
|
||||
val root = onScreenControls?.parent as? android.view.ViewGroup ?: return null
|
||||
val view = com.droiddeck.launcher.input.SteamTouchControls(this, device,
|
||||
onMenu = { drawerOpen = true }, onKeyboard = ::togglePcKeyboard)
|
||||
view.visibility = View.GONE
|
||||
view.onOptions = { options ->
|
||||
steamTouchOptions = options
|
||||
applyTouchRouting()
|
||||
}
|
||||
// Paste: the clipboard is shared with the session's X servers; Ctrl+V types it.
|
||||
view.onPaste = {
|
||||
sendGuestKey(29, true); sendGuestKey(47, true) // KEY_LEFTCTRL, KEY_V
|
||||
sendGuestKey(47, false); sendGuestKey(29, false)
|
||||
}
|
||||
// The client reads a controller's config when it connects: after a save, plug the device
|
||||
// out and back in so it takes the new one.
|
||||
view.onSaved = {
|
||||
device.plugged = false
|
||||
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
|
||||
}
|
||||
root.addView(view, root.indexOfChild(onScreenControls) + 1)
|
||||
steamTouchControls = view
|
||||
steamTouchAvailable = true
|
||||
return view
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
if (pipUi) {
|
||||
// In picture-in-picture, or with the unfolded screen's gamepad below the game, the on-screen
|
||||
// pad would only cover the picture or that panel.
|
||||
if (pipUi || unfoldedPanel != null) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
// Steam's touch controller stands in for the app's pad, and is plugged into the client only
|
||||
// while it is shown - as Steam Link withdraws its controls when a physical pad is in use.
|
||||
val wantsSteam = forced == SessionPrefs.OSC_STEAM_TOUCH && SessionState.mode == SessionService.MODE_STEAM
|
||||
val steam = if (wantsSteam) steamTouch() else null
|
||||
// The service makes the device while the session starts, after this activity: the app's
|
||||
// own pad until then, and another look in a moment.
|
||||
if (wantsSteam && steam == null && !isFinishing && !isDestroyed) {
|
||||
uiHandler.removeCallbacks(steamTouchRetry)
|
||||
uiHandler.postDelayed(steamTouchRetry, 1000)
|
||||
}
|
||||
if (steam == null) {
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
}
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
SessionPrefs.OSC_ALWAYS -> true
|
||||
SessionPrefs.OSC_STEAM_QAM -> true
|
||||
SessionPrefs.OSC_NEVER -> false
|
||||
// Shown throughout a Steam session: a handheld's built-in pad would keep Auto off for good.
|
||||
// Outside Steam it is Auto.
|
||||
SessionPrefs.OSC_STEAM_TOUCH -> SessionState.mode == SessionService.MODE_STEAM ||
|
||||
(!PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP)
|
||||
// Auto: the touch pad when there is no controller - except on the desktop, where the
|
||||
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
|
||||
SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (steam != null) {
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
if (!show) steam.releaseAll()
|
||||
steam.visibility = if (show) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
applyTouchRouting()
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
|
||||
return
|
||||
}
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
@@ -1894,6 +2142,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: android.content.res.Configuration) {
|
||||
super.onPictureInPictureModeChanged(inPip, newConfig)
|
||||
if (::pip.isInitialized) pip.changed(inPip)
|
||||
// The panel may have changed while the session was a thumbnail (folded or unfolded in PiP).
|
||||
if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
|
||||
@@ -1950,6 +2201,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (runtimeRemovalBlocked) return
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
@@ -2021,10 +2273,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
// ALWAYS from Android 11: SHORT_EDGES left a black strip along a camera on the long edge,
|
||||
// which is where a Fold's inner-screen camera sits with the device held in landscape.
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
layoutInDisplayCutoutMode = if (Build.VERSION.SDK_INT >= 30)
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||
else WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2041,6 +2296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
uiHandler.removeCallbacks(steamTouchRetry)
|
||||
if (runtimeRemovalBlocked) { super.onDestroy(); return }
|
||||
// Deliberately does NOT end the session: this activity can be destroyed while the user is
|
||||
// in another app, and the whole point of the service is that Steam survives that.
|
||||
@@ -2052,6 +2308,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
steamController?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
|
||||
* launching and quitting games and sending input only need the shell (the provider's DUMP
|
||||
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
|
||||
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
|
||||
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
|
||||
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
|
||||
*/
|
||||
object AgentAccess {
|
||||
const val MAX_REQUEST_CHARS = 256 * 1024
|
||||
private const val PREFS = "agent"
|
||||
private const val KEY_COMMANDS = "commands"
|
||||
|
||||
/** The preferences file the bridge must never write: set-pref refuses it. */
|
||||
const val PROTECTED_PREFS = PREFS
|
||||
|
||||
fun commandsAllowed(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
/** The Setup toggle's own state. */
|
||||
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
fun setCommandsEnabled(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
|
||||
if (!enabled) runCatching { AgentEnv.revoke(context) }
|
||||
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
|
||||
}
|
||||
|
||||
fun requireCommands(context: Context) {
|
||||
if (!commandsAllowed(context)) throw AgentException(
|
||||
"AGENT_COMMANDS_DISABLED",
|
||||
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
|
||||
)
|
||||
}
|
||||
|
||||
fun requireStart(context: Context, request: org.json.JSONObject) {
|
||||
if (request.optString("mode") == "run") requireCommands(context)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
|
||||
class AgentException(val code: String, message: String) : RuntimeException(message)
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import android.Manifest
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
@@ -10,13 +11,14 @@ import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
@@ -25,7 +27,14 @@ import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** A shell-only control surface, installed only by debug builds. */
|
||||
/**
|
||||
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
|
||||
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
|
||||
* reach into the app's sandbox are further gated by [AgentAccess].
|
||||
*
|
||||
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
|
||||
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
|
||||
*/
|
||||
class AgentBridgeProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
val appContext = context?.applicationContext ?: return false
|
||||
@@ -48,22 +57,58 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
}
|
||||
|
||||
val response = try {
|
||||
when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
val request = decode(extras?.getString(EXTRA_REQUEST))
|
||||
// Binder identity is the shell's; everything below acts as the app.
|
||||
val token = Binder.clearCallingIdentity()
|
||||
try {
|
||||
dispatch(context, method, request)
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token)
|
||||
}
|
||||
} catch (e: AgentException) {
|
||||
error(e.code, e.message ?: e.code)
|
||||
} catch (e: Exception) {
|
||||
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
|
||||
val json = response.toString().let {
|
||||
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, json) }
|
||||
}
|
||||
|
||||
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
"launch" -> launch(context, request)
|
||||
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
|
||||
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
|
||||
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
|
||||
"input" -> AgentInput.handle(request)
|
||||
"ui" -> AgentUi.handle(request)
|
||||
"guest" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
|
||||
}
|
||||
"cdp" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
|
||||
}
|
||||
"env" -> env(context, request)
|
||||
"override" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
|
||||
}
|
||||
"prefs" -> prefs(context, request)
|
||||
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
|
||||
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
val session = JSONObject()
|
||||
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
|
||||
.put("phase", SessionState.phase.name)
|
||||
@@ -91,22 +136,34 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
|
||||
session.put("failure", failure)
|
||||
|
||||
// gamescope's focus as last published by the guest agent, without the window lists
|
||||
// (the focus command returns those, fresh).
|
||||
val focus = AgentGuest.focus(context)?.let { snap ->
|
||||
JSONObject()
|
||||
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
|
||||
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
|
||||
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
|
||||
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
|
||||
.put("seq", snap.optInt("seq"))
|
||||
.put("t", snap.optLong("t"))
|
||||
}
|
||||
session.put("focus", focus ?: JSONObject.NULL)
|
||||
|
||||
val hello = AgentGuest.hello(context)
|
||||
return JSONObject()
|
||||
.put("schema", 1)
|
||||
.put("schema", SCHEMA)
|
||||
.put("build", BuildConfig.BUILD_LABEL)
|
||||
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
|
||||
.put("runtime", JSONObject()
|
||||
.put("installed", LinuxRuntime.isInstalled(context))
|
||||
.put("version", runtimeVersion ?: JSONObject.NULL))
|
||||
.put("agent", JSONObject()
|
||||
.put("guest", hello != null)
|
||||
.put("guestFocus", hello?.optBoolean("focus") ?: false)
|
||||
.put("commands", AgentAccess.commandsAllowed(context)))
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
private fun latestSessionDirectory(): File? {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
|
||||
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
|
||||
}
|
||||
|
||||
private fun stop(context: Context): JSONObject {
|
||||
if (SessionState.running) {
|
||||
SessionService.stop(context)
|
||||
@@ -131,6 +188,67 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
return JSONObject().put("ok", true).put("command", "resume")
|
||||
}
|
||||
|
||||
private fun launch(context: Context, request: JSONObject): JSONObject {
|
||||
val appId = request.optString("appId")
|
||||
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
|
||||
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
|
||||
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
|
||||
}
|
||||
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
|
||||
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
|
||||
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
|
||||
}
|
||||
|
||||
private fun env(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val result = when (val op = request.optString("op", "list")) {
|
||||
"list" -> AgentEnv.list(context)
|
||||
"set" -> {
|
||||
val lines = request.optJSONArray("lines") ?: JSONArray()
|
||||
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
|
||||
}
|
||||
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
|
||||
}
|
||||
return result.put("ok", true).put("command", "env")
|
||||
}
|
||||
|
||||
private fun prefs(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val file = request.optString("file")
|
||||
val result = when (val op = request.optString("op", "get")) {
|
||||
"get" -> AgentPrefs.get(context, file)
|
||||
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
|
||||
if (request.isNull("value")) null else request.optString("value"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
|
||||
}
|
||||
return result.put("ok", true).put("command", "prefs")
|
||||
}
|
||||
|
||||
/** A guest agent request; its own answer already carries ok or error. */
|
||||
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
|
||||
AgentGuest.call(context, request, timeoutMs)
|
||||
|
||||
/** The guest's own timeout plus slack for the round trip. */
|
||||
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
|
||||
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
|
||||
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
|
||||
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
|
||||
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
|
||||
}
|
||||
return (seconds * 1000).toLong() + slackMs
|
||||
}
|
||||
|
||||
private fun decode(encoded: String?): JSONObject {
|
||||
if (encoded.isNullOrBlank()) return JSONObject()
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
|
||||
}
|
||||
}
|
||||
|
||||
private fun error(code: String, message: String) = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("error", JSONObject().put("code", code).put("message", message))
|
||||
@@ -143,6 +261,9 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
|
||||
companion object {
|
||||
const val RESULT_JSON = "json"
|
||||
const val EXTRA_REQUEST = "request"
|
||||
/** 2 added agent and session.focus; everything in 1 is unchanged. */
|
||||
const val SCHEMA = 2
|
||||
private val recoveryComplete = CountDownLatch(1)
|
||||
|
||||
fun awaitRecovery() {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
|
||||
/**
|
||||
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
|
||||
*
|
||||
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
|
||||
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
|
||||
* "persistent" ones stay until cleared.
|
||||
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
|
||||
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
|
||||
* a gamescope build under test, say. Removed when that session ends.
|
||||
*
|
||||
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
|
||||
*/
|
||||
object AgentEnv {
|
||||
private const val TAG = "AgentEnv"
|
||||
private const val FILE = "agent-env.json"
|
||||
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
|
||||
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
|
||||
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val lock = Any()
|
||||
|
||||
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
|
||||
|
||||
fun list(context: Context): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
load(context)
|
||||
}
|
||||
|
||||
/** Add [lines] for the next session ([persistent] = every session until cleared). */
|
||||
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
|
||||
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
|
||||
}
|
||||
val state = load(context)
|
||||
val key = if (persistent) "persistent" else "pending"
|
||||
val merged = merge(strings(state.optJSONArray(key)), lines)
|
||||
state.put(key, JSONArray(merged))
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Drop lines: [scope] is "next", "persistent" or "all". */
|
||||
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
val state = load(context)
|
||||
when (scope) {
|
||||
"next" -> state.put("pending", JSONArray())
|
||||
"persistent" -> state.put("persistent", JSONArray())
|
||||
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
|
||||
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
|
||||
}
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Install agent-inbox/[name] as an override for the next session. */
|
||||
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
|
||||
val source = File(inbox(context), name)
|
||||
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
|
||||
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
|
||||
val target = File(bin(context), name)
|
||||
target.parentFile?.mkdirs()
|
||||
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
|
||||
try {
|
||||
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
|
||||
}
|
||||
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
|
||||
}
|
||||
source.delete()
|
||||
val state = load(context)
|
||||
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
|
||||
save(context, state)
|
||||
state.put("installed", "$GUEST_BIN/$name")
|
||||
}
|
||||
|
||||
/**
|
||||
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
|
||||
* and overrides become this session's own, to be dropped by [endSession].
|
||||
*/
|
||||
fun beginSession(context: Context): List<String> = synchronized(lock) {
|
||||
if (!AgentAccess.commandsAllowed(context)) {
|
||||
revoke(context)
|
||||
return@synchronized emptyList()
|
||||
}
|
||||
val state = load(context)
|
||||
val active = strings(state.optJSONArray("pending"))
|
||||
val overrides = strings(state.optJSONArray("pendingOverrides"))
|
||||
state.put("active", JSONArray(active)).put("pending", JSONArray())
|
||||
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
|
||||
save(context, state)
|
||||
// Anything else there was left by a session that never reached endSession (the app died).
|
||||
bin(context).listFiles()
|
||||
?.filter { it.name !in overrides }?.forEach { it.delete() }
|
||||
val lines = strings(state.optJSONArray("persistent")) + active
|
||||
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
|
||||
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
|
||||
withPath
|
||||
}
|
||||
|
||||
fun revoke(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
|
||||
save(context, state)
|
||||
bin(context).listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** At session end: this session's lines and override binaries go. */
|
||||
fun endSession(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
val overrides = strings(state.optJSONArray("activeOverrides"))
|
||||
val pending = strings(state.optJSONArray("pendingOverrides"))
|
||||
val bin = bin(context)
|
||||
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
|
||||
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
|
||||
save(context, state)
|
||||
}
|
||||
|
||||
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
|
||||
private fun pathOf(lines: List<String>) =
|
||||
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
|
||||
|
||||
private fun bin(context: Context): File {
|
||||
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
|
||||
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
|
||||
var path = directory
|
||||
while (path != root) {
|
||||
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
|
||||
path = path.parent
|
||||
}
|
||||
return directory.toFile()
|
||||
}
|
||||
|
||||
private fun merge(existing: List<String>, added: List<String>): List<String> {
|
||||
val keys = added.map { it.substringBefore('=') }.toSet()
|
||||
return existing.filter { it.substringBefore('=') !in keys } + added
|
||||
}
|
||||
|
||||
private fun strings(array: JSONArray?): List<String> =
|
||||
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
|
||||
|
||||
private fun load(context: Context): JSONObject {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
|
||||
if (!state.has(key)) state.put(key, JSONArray())
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: JSONObject) {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val staged = File(context.filesDir, "$FILE.tmp")
|
||||
staged.writeText(state.toString())
|
||||
if (!staged.renameTo(file)) {
|
||||
staged.delete()
|
||||
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
|
||||
object AgentPrefs {
|
||||
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
|
||||
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
|
||||
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
|
||||
val previous = prefs.all[key]
|
||||
val editor = prefs.edit()
|
||||
try {
|
||||
when (type) {
|
||||
"remove" -> editor.remove(key)
|
||||
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
|
||||
"int" -> editor.putInt(key, value!!.toInt())
|
||||
"long" -> editor.putLong(key, value!!.toLong())
|
||||
"float" -> editor.putFloat(key, value!!.toFloat())
|
||||
"string" -> editor.putString(key, value ?: "")
|
||||
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
if (e is AgentException) throw e
|
||||
throw AgentException("INVALID_PREF", "The value is not a $type")
|
||||
}
|
||||
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
|
||||
return JSONObject().put("file", file).put("key", key).put("type", type)
|
||||
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
fun get(context: Context, file: String): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
|
||||
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
|
||||
}
|
||||
|
||||
private fun validateFile(file: String) {
|
||||
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
|
||||
throw AgentException("INVALID_PREF", "Preference files must be simple names")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
|
||||
* go in as files under the session root's agent directory and answers come back the same way, so
|
||||
* whatever runs does so inside the live session with its own environment.
|
||||
*/
|
||||
object AgentGuest {
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
private val lock = Any()
|
||||
private var observer: FileObserver? = null
|
||||
private var lastFocusKey: String? = null
|
||||
|
||||
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
|
||||
|
||||
/**
|
||||
* Before each session: nothing of the last one's daemon, answers or focus may be read as
|
||||
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
|
||||
* session's events.jsonl.
|
||||
*/
|
||||
fun reset(context: Context) {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@Suppress("DEPRECATION")
|
||||
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
when (path) {
|
||||
"hello.json" -> readJson(File(dir, path))?.let {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
}
|
||||
}
|
||||
}.also { it.startWatching() }
|
||||
}
|
||||
}
|
||||
|
||||
/** At the end of a session: no more events for a folder that is being closed. */
|
||||
fun stop() {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
|
||||
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
|
||||
)
|
||||
val key = fields.values.joinToString("|")
|
||||
synchronized(lock) {
|
||||
if (key == lastFocusKey) return
|
||||
lastFocusKey = key
|
||||
}
|
||||
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
/** The daemon's hello, when it is running in the current session. */
|
||||
fun hello(context: Context): JSONObject? {
|
||||
if (!SessionState.running) return null
|
||||
val hello = readJson(File(dir(context), "hello.json")) ?: return null
|
||||
val pid = hello.optInt("pid", -1)
|
||||
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
|
||||
}
|
||||
|
||||
/** The newest focus snapshot the daemon published, when a session is running. */
|
||||
fun focus(context: Context): JSONObject? =
|
||||
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
|
||||
|
||||
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
|
||||
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
|
||||
val dir = dir(context)
|
||||
val requests = File(dir, "req").apply { mkdirs() }
|
||||
val answers = File(dir, "resp").apply { mkdirs() }
|
||||
val id = UUID.randomUUID().toString()
|
||||
val staged = File(requests, "$id.json.tmp")
|
||||
val published = File(requests, "$id.json")
|
||||
val answer = File(answers, "$id.json")
|
||||
staged.writeText(request.toString())
|
||||
if (!staged.renameTo(published)) {
|
||||
staged.delete()
|
||||
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (answer.isFile) {
|
||||
if (answer.length() > 1024 * 1024) {
|
||||
answer.delete()
|
||||
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
|
||||
}
|
||||
val result = readJson(answer)
|
||||
answer.delete()
|
||||
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
published.delete()
|
||||
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
|
||||
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
|
||||
}
|
||||
|
||||
private fun readJson(file: File): JSONObject? = try {
|
||||
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Input into the running session the way a player gives it: keys and touches through the
|
||||
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
|
||||
* on-screen controls drive, so games that ignore touch still see a controller.
|
||||
*/
|
||||
object AgentInput {
|
||||
/** Out of the way of real fingers, which Android numbers from 0. */
|
||||
private const val TOUCH_ID = 9
|
||||
private const val TOUCH_SCALE_X = 1919f
|
||||
private const val TOUCH_SCALE_Y = 1079f
|
||||
|
||||
fun handle(request: JSONObject): JSONObject {
|
||||
if (!SessionState.running || !SessionState.firstFrameSeen) {
|
||||
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
|
||||
}
|
||||
return when (val type = request.optString("type")) {
|
||||
"key" -> key(request)
|
||||
"text" -> text(request)
|
||||
"tap" -> tap(request)
|
||||
"swipe" -> swipe(request)
|
||||
"pad" -> pad(request)
|
||||
"stick" -> stick(request)
|
||||
"trigger" -> trigger(request)
|
||||
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
|
||||
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
|
||||
}
|
||||
|
||||
private fun key(request: JSONObject): JSONObject {
|
||||
val name = request.optString("key")
|
||||
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
|
||||
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
|
||||
val modifiers = request.optJSONArray("modifiers")?.let { array ->
|
||||
List(array.length()) { i ->
|
||||
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
|
||||
}
|
||||
}.orEmpty()
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> WaylandCompositor.nativeSendKey(code, 1)
|
||||
"up" -> WaylandCompositor.nativeSendKey(code, 0)
|
||||
"tap" -> {
|
||||
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
hold(request, 40)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("code", code)
|
||||
}
|
||||
|
||||
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
|
||||
private fun text(request: JSONObject): JSONObject {
|
||||
val text = request.optString("text")
|
||||
if (request.optBoolean("commit")) {
|
||||
WaylandCompositor.textInputCommit(text)
|
||||
return JSONObject().put("length", text.length).put("via", "text-input")
|
||||
}
|
||||
val missing = text.filter { TYPED[it] == null }
|
||||
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
|
||||
val delay = request.optLong("delayMs", 15)
|
||||
for (ch in text) {
|
||||
val (code, shift) = TYPED.getValue(ch)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
|
||||
Thread.sleep(delay)
|
||||
}
|
||||
return JSONObject().put("length", text.length).put("via", "keys")
|
||||
}
|
||||
|
||||
private fun tap(request: JSONObject): JSONObject {
|
||||
val (x, y) = point(request, "x", "y")
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
|
||||
hold(request, 60)
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
|
||||
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
|
||||
}
|
||||
|
||||
private fun swipe(request: JSONObject): JSONObject {
|
||||
val (x1, y1) = point(request, "x", "y")
|
||||
val (x2, y2) = point(request, "x2", "y2")
|
||||
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
|
||||
val steps = (duration / 16).toInt().coerceAtLeast(2)
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
|
||||
for (i in 1..steps) {
|
||||
Thread.sleep(duration / steps)
|
||||
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
|
||||
}
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
|
||||
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
|
||||
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
|
||||
* fractions (space "fraction").
|
||||
*/
|
||||
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
|
||||
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
|
||||
val x = request.getDouble(xKey)
|
||||
val y = request.getDouble(yKey)
|
||||
val (fx, fy) = when (val space = request.optString("space", "output")) {
|
||||
"fraction" -> x to y
|
||||
"output" -> {
|
||||
val (w, h) = SessionState.outputSize
|
||||
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
|
||||
}
|
||||
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
|
||||
}
|
||||
|
||||
private fun pad(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val name = request.optString("button").lowercase()
|
||||
if (name == "qam") {
|
||||
bridge.triggerQam()
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
val button = BUTTONS[name]
|
||||
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
|
||||
val apply = { s: PadState, down: Boolean ->
|
||||
when (name) {
|
||||
"up" -> s.up = down
|
||||
"down" -> s.down = down
|
||||
"left" -> s.left = down
|
||||
"right" -> s.right = down
|
||||
else -> s.press(button!!, down)
|
||||
}
|
||||
}
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> bridge.applyTouch { apply(it, true) }
|
||||
"up" -> bridge.applyTouch { apply(it, false) }
|
||||
"tap" -> {
|
||||
bridge.applyTouch { apply(it, true) }
|
||||
hold(request, 120)
|
||||
bridge.applyTouch { apply(it, false) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
|
||||
private fun stick(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("stick", "left") == "right"
|
||||
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
|
||||
hold(request, 300)
|
||||
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
|
||||
return JSONObject().put("stick", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun trigger(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("trigger", "right") == "right"
|
||||
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
|
||||
hold(request, 200)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
|
||||
return JSONObject().put("trigger", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun hold(request: JSONObject, defaultMs: Long) =
|
||||
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
|
||||
|
||||
private val DPAD = setOf("up", "down", "left", "right")
|
||||
|
||||
private val BUTTONS = mapOf(
|
||||
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
|
||||
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
|
||||
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
|
||||
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
|
||||
)
|
||||
|
||||
/** Linux evdev codes (input-event-codes.h) by name. */
|
||||
private val KEYS: Map<String, Int> = buildMap {
|
||||
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
|
||||
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
|
||||
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
|
||||
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
|
||||
(1..10).forEach { put("F$it", 58 + it) }
|
||||
put("F11", 87); put("F12", 88)
|
||||
putAll(mapOf(
|
||||
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
|
||||
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
|
||||
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
|
||||
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
|
||||
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
|
||||
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
|
||||
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
|
||||
))
|
||||
}
|
||||
|
||||
/** US-layout characters as (evdev code, shift). */
|
||||
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
|
||||
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
|
||||
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
|
||||
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
|
||||
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
|
||||
putAll(mapOf(
|
||||
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
|
||||
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
|
||||
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
|
||||
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
|
||||
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
|
||||
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
|
||||
'/' to (53 to false), '?' to (53 to true),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import org.json.JSONObject
|
||||
class AgentStartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
|
||||
if (request == null) {
|
||||
finish()
|
||||
@@ -38,6 +39,10 @@ class AgentStartActivity : Activity() {
|
||||
Log.e(TAG, "missing agent start request")
|
||||
return null
|
||||
}
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
|
||||
Log.w(TAG, "agent start request is too large")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
@@ -47,6 +52,13 @@ class AgentStartActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun startSession(request: JSONObject) {
|
||||
try {
|
||||
AgentAccess.requireStart(this, request)
|
||||
} catch (e: AgentException) {
|
||||
Log.w(TAG, e.code)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val requestedMode = request.optString("mode")
|
||||
val sessionMode = when (requestedMode) {
|
||||
"steam" -> SessionService.MODE_STEAM
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inspector.WindowInspector
|
||||
import androidx.compose.ui.platform.ViewRootForTest
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsConfiguration
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
|
||||
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
|
||||
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
|
||||
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
|
||||
* and set-text act on a node by tag or text, so a test needs no coordinates.
|
||||
*/
|
||||
object AgentUi {
|
||||
private const val TIMEOUT_MS = 5_000L
|
||||
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
|
||||
private const val GATE_TAG = "setting-agent-commands"
|
||||
|
||||
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
|
||||
"dump" -> onMain { dump(request.optBoolean("all")) }
|
||||
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
|
||||
"set-text" -> onMain {
|
||||
val text = request.optString("text")
|
||||
act(request) { node ->
|
||||
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
|
||||
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
|
||||
}
|
||||
}
|
||||
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
|
||||
}.put("ok", true).put("command", "ui")
|
||||
|
||||
private fun roots(): List<Pair<View, ViewRootForTest>> {
|
||||
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
|
||||
val found = mutableListOf<Pair<View, ViewRootForTest>>()
|
||||
fun walk(view: View) {
|
||||
if (view is ViewRootForTest) found += view to view
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
|
||||
return found
|
||||
}
|
||||
|
||||
private fun dump(all: Boolean): JSONObject {
|
||||
val windows = JSONArray()
|
||||
for ((view, root) in roots()) {
|
||||
val nodes = JSONArray()
|
||||
val location = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val json = describe(node, location)
|
||||
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
windows.put(JSONObject()
|
||||
.put("display", view.display?.displayId ?: JSONObject.NULL)
|
||||
.put("window", view.rootView.javaClass.simpleName)
|
||||
.put("nodes", nodes))
|
||||
}
|
||||
return JSONObject().put("windows", windows)
|
||||
}
|
||||
|
||||
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
|
||||
val config = node.config
|
||||
val json = JSONObject().put("id", node.id)
|
||||
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
|
||||
val text = textFields(config)
|
||||
text.keys().forEach { json.put(it, text.get(it)) }
|
||||
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
|
||||
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
|
||||
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
|
||||
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
|
||||
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
|
||||
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
|
||||
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
|
||||
val b = node.boundsInWindow
|
||||
json.put("bounds", JSONArray()
|
||||
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
|
||||
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
|
||||
return json
|
||||
}
|
||||
|
||||
internal fun textFields(config: SemanticsConfiguration): JSONObject {
|
||||
val password = config.contains(SemanticsProperties.Password)
|
||||
val json = JSONObject()
|
||||
for ((key, value) in listOf(
|
||||
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
|
||||
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
|
||||
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
|
||||
"state" to config.getOrNull(SemanticsProperties.StateDescription),
|
||||
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a request names by "tag" (exact) or "match" (text or description containing it,
|
||||
* ignoring case); "index" picks among several, in tree order.
|
||||
*/
|
||||
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
|
||||
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
|
||||
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
|
||||
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
|
||||
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
|
||||
for ((view, root) in roots()) {
|
||||
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val config = node.config
|
||||
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
|
||||
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
|
||||
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
|
||||
.contains(text!!, ignoreCase = true)
|
||||
if (hit) matches += node to origin
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
}
|
||||
val index = request.optInt("index", 0)
|
||||
val (node, origin) = matches.getOrNull(index)
|
||||
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
|
||||
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
|
||||
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
|
||||
}
|
||||
action(node)
|
||||
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
|
||||
}
|
||||
|
||||
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
|
||||
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
|
||||
action()
|
||||
}
|
||||
|
||||
private fun <T> onMain(block: () -> T): T {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) return block()
|
||||
val result = AtomicReference<Result<T>>()
|
||||
val done = CountDownLatch(1)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
result.set(runCatching(block))
|
||||
done.countDown()
|
||||
}
|
||||
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
|
||||
return result.get().getOrThrow()
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ public class PulseAudioComponent extends SessionPart {
|
||||
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
|
||||
public static final String SOCKET_NAME = "PS0";
|
||||
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
|
||||
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5";
|
||||
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
|
||||
|
||||
|
||||
|
||||
private final File workingDir;
|
||||
/** Where the daemon's own output is kept for this session, or null for logcat only. */
|
||||
@@ -40,8 +42,6 @@ public class PulseAudioComponent extends SessionPart {
|
||||
* turns that one stream into a source the client can see, named DirectAudioMic.
|
||||
*/
|
||||
private final String micFifoPath;
|
||||
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
|
||||
private String relaySocketPath;
|
||||
private volatile int pid = -1;
|
||||
|
||||
public PulseAudioComponent(Context context) {
|
||||
@@ -54,15 +54,6 @@ public class PulseAudioComponent extends SessionPart {
|
||||
this.micFifoPath = micFifoPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
|
||||
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
|
||||
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
|
||||
*/
|
||||
public void setRelaySocket(String path) {
|
||||
this.relaySocketPath = path;
|
||||
}
|
||||
|
||||
/** CRC-32 of the bundled pulseaudio.tzst, or "?" if it cannot be read. About 600 KB; cheap. */
|
||||
private String bundleChecksum() {
|
||||
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
|
||||
@@ -106,6 +97,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
String have = FileUtils.readString(stamp);
|
||||
String want = BUNDLE_STAMP + "-" + bundleChecksum();
|
||||
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|
||||
|| !new File(modulesDir, "arm64/module-directaudio-native-sink.so").isFile()
|
||||
|| !new File(workingDir, "pactl").isFile()
|
||||
|| have == null || !want.equals(have.trim())) {
|
||||
Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")");
|
||||
@@ -139,13 +131,15 @@ public class PulseAudioComponent extends SessionPart {
|
||||
// the sink to 0% and the session plays silence.
|
||||
// The sink's name is what the client's Audio settings show as the output device, so it
|
||||
// says which road the sound takes.
|
||||
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
|
||||
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
} else {
|
||||
config.add("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
}
|
||||
// The client's sound: DirectAudio's own engine, in this process, one step from Android.
|
||||
// decay=0: shrinking the device buffer after a quiet spell probes below what works and that
|
||||
// probe is a click; the client plays menus. Between .nofail and .fail a module that cannot
|
||||
// open its stream does not take the daemon down; ensureClientSink() then loads the plain
|
||||
// AAudio sink in its place.
|
||||
config.add(".nofail");
|
||||
config.add("load-module module-directaudio-native-sink sink_name=DirectAudio performance_mode=1 adaptive=1 decay=0 limiter=1 watchdog=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
config.add(".fail");
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
|
||||
// grants another input rate, so the daemon is never told a rate the bytes are not.
|
||||
@@ -177,6 +171,84 @@ public class PulseAudioComponent extends SessionPart {
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
});
|
||||
Thread guard = new Thread(() -> ensureClientSink(out), "pulse-sink-guard");
|
||||
guard.setDaemon(true);
|
||||
guard.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback: once the daemon answers, if the DirectAudio sink is not
|
||||
* there (the module failed to load or to open its stream), load the plain AAudio sink so the
|
||||
* client is never left without an output. Says so in the audio log either way.
|
||||
*/
|
||||
private void ensureClientSink(java.io.PrintWriter out) {
|
||||
try {
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "ensureClientSink failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
|
||||
private String pactl(String... args) {
|
||||
File pactl = new File(workingDir, "pactl");
|
||||
if (!pactl.isFile() || pid <= 1) return null;
|
||||
java.lang.Process process = null;
|
||||
try {
|
||||
File modules = new File(workingDir, "modules/arm64");
|
||||
ArrayList<String> cmd = new ArrayList<>();
|
||||
cmd.add(pactl.getAbsolutePath());
|
||||
java.util.Collections.addAll(cmd, args);
|
||||
ProcessBuilder builder = new ProcessBuilder(cmd);
|
||||
builder.directory(workingDir);
|
||||
builder.redirectErrorStream(true);
|
||||
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
|
||||
builder.environment().put("HOME", workingDir.getAbsolutePath());
|
||||
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
|
||||
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
|
||||
process = builder.start();
|
||||
String output;
|
||||
try (InputStream in = process.getInputStream();
|
||||
java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
outStream.write(buf, 0, n);
|
||||
}
|
||||
output = outStream.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
}
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
return null;
|
||||
}
|
||||
return process.exitValue() == 0 ? output : null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -53,7 +53,7 @@ object DeviceReport {
|
||||
b.append("DroidDeck session report\n")
|
||||
b.append("========================\n")
|
||||
k("Written", SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date()))
|
||||
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (labwc/LXQt)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
|
||||
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (KDE Plasma)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
|
||||
|
||||
h("App")
|
||||
runCatching {
|
||||
@@ -152,7 +152,6 @@ object DeviceReport {
|
||||
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
|
||||
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
|
||||
@@ -167,7 +166,6 @@ object DeviceReport {
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
k("On-screen controls", SessionPrefs.oscMode(context))
|
||||
k("Touch mode", SessionPrefs.touchMode(context))
|
||||
@@ -181,10 +179,16 @@ object DeviceReport {
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
h("Device switch files")
|
||||
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
|
||||
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
|
||||
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
|
||||
}
|
||||
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
"droiddeck-no-hud")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@ object DeviceSupport {
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
|
||||
}
|
||||
|
||||
/** False on the Odin 3 (CQ8725S): a low-latency Wi-Fi lock makes its WLAN firmware hang, and
|
||||
* that firmware reboots the whole device instead of restarting Wi-Fi. */
|
||||
fun lowLatencyWifiSafe(): Boolean = Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S"
|
||||
}
|
||||
@@ -31,7 +31,8 @@ object GameEnvironment {
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
// .NET 7 and later reserve 256 GB of address space for the GC heap at startup, more than an
|
||||
// Android process has free, and the runtime fails to start. 32 GB is plenty for a game.
|
||||
"DOTNET_GCRegionRange" to "0x800000000",
|
||||
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
|
||||
"DOTNET_GCRegionRange" to "800000000",
|
||||
// .NET's W^X writes JIT code through a second mapping that FEX does not watch, so FEX keeps
|
||||
// running the stale translation and the game hangs at startup.
|
||||
"DOTNET_EnableWriteXorExecute" to "0",
|
||||
|
||||
@@ -40,7 +40,7 @@ object HostProcess {
|
||||
if (onLine == null) builder.redirectOutput(File("/dev/null"))
|
||||
val process = builder.start()
|
||||
val pid = pidOf(process)
|
||||
Log.i(TAG, "started pid $pid: $command")
|
||||
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
|
||||
if (onLine != null) {
|
||||
Thread({
|
||||
try {
|
||||
@@ -59,7 +59,7 @@ object HostProcess {
|
||||
}
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "could not start: $command", e)
|
||||
Log.e(TAG, "could not start: ${argv.first()}", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,18 +60,45 @@ object LogRedactor {
|
||||
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
|
||||
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
|
||||
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
|
||||
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
|
||||
private val MAC_FIELD = Regex(
|
||||
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
|
||||
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
|
||||
)
|
||||
private val DEVICE_IDENTIFIER = Regex(
|
||||
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
|
||||
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
|
||||
)
|
||||
|
||||
/**
|
||||
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
|
||||
* user who signs in with a plain account name, not an email, had it in every log. The single
|
||||
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
|
||||
*/
|
||||
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
|
||||
private val LOGIN_STATE = Regex("(?i)(OnLoginStateChange:? )([^\\s<]\\S*)")
|
||||
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
|
||||
|
||||
/**
|
||||
* An account name as a field: `AccountName`, `account_name`, `username` (`"AccountName"
|
||||
* "someone"` in a VDF, `account_name=...`, `"username": "..."`) and `login` - the last only
|
||||
* as `login=`, a quoted key or a VDF pair, so "Login: OnLoginStateChange ..." keeps its label.
|
||||
* A quoted value is blanked whole; one already blanked is left as it is.
|
||||
*/
|
||||
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
|
||||
private val ACCOUNT_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|user[_-]?name|epicusername)" +
|
||||
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
private val LOGIN_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
|
||||
/** A learned pattern and the text (lowercase) a line must hold for it to match; null when none can be said. */
|
||||
private class Learned(val regex: Regex, val needs: String?)
|
||||
|
||||
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
|
||||
@Volatile
|
||||
private var accounts: List<Regex> = emptyList()
|
||||
private var accounts: List<Learned> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
|
||||
@@ -79,12 +106,13 @@ object LogRedactor {
|
||||
* Names under three characters are skipped: blanking every "a" would ruin a log.
|
||||
*/
|
||||
fun learnAccounts(loginUsers: java.io.File) {
|
||||
val found = ArrayList<Regex>()
|
||||
val found = ArrayList<Learned>()
|
||||
try {
|
||||
if (loginUsers.isFile) {
|
||||
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
|
||||
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
|
||||
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
|
||||
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
|
||||
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -101,7 +129,7 @@ object LogRedactor {
|
||||
|
||||
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
|
||||
@Volatile
|
||||
private var own: List<Regex> = emptyList()
|
||||
private var own: List<Learned> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns the device's addresses from the link file the app writes for the session
|
||||
@@ -109,7 +137,7 @@ object LogRedactor {
|
||||
* private, link-local and loopback addresses identify nobody and are left out.
|
||||
*/
|
||||
fun learnOwnAddresses(linkFile: java.io.File) {
|
||||
val found = ArrayList<Regex>()
|
||||
val found = ArrayList<Learned>()
|
||||
try {
|
||||
if (linkFile.isFile) linkFile.forEachLine { line ->
|
||||
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
|
||||
@@ -118,9 +146,12 @@ object LogRedactor {
|
||||
val groups = expand6(addr)?.take(4) ?: return@forEachLine
|
||||
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
|
||||
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
|
||||
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
|
||||
found += Learned(
|
||||
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
|
||||
groups[0].trimStart('0').ifEmpty { "0" },
|
||||
)
|
||||
}
|
||||
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
|
||||
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -196,39 +227,130 @@ object LogRedactor {
|
||||
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
|
||||
* out.
|
||||
*/
|
||||
const val RULES_VERSION = 1
|
||||
const val RULES_VERSION = 3
|
||||
|
||||
/**
|
||||
* A private IPv4 address (10/8, 172.16/12, 192.168/16, link-local 169.254/16): the home
|
||||
* network's layout, e.g. its router as the resolver. Blanked in a shared zip only; public
|
||||
* server addresses stay. Not part of a longer dotted number (a version like 10.0.19041.1).
|
||||
*/
|
||||
private val LAN_IPV4 = Regex(
|
||||
"(?<![\\d.])(?:10\\.\\d{1,3}|192\\.168|172\\.(?:1[6-9]|2\\d|3[01])|169\\.254)\\.\\d{1,3}\\.\\d{1,3}(?!\\.?\\d)"
|
||||
)
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last pass for a file going into a shared zip: [redact], then the rules the stores' log
|
||||
* uses too ([SecretScrub]) - URLs without query, fragment and user:password and with any
|
||||
* token-bearing path segment blanked, token values and Authorization / Cookie headers blanked
|
||||
* - and private IPv4 addresses as `<lan-address>`. A line already clean comes out unchanged.
|
||||
*/
|
||||
fun redactForShare(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
|
||||
LAN_IPV4.replace(out, "<lan-address>")
|
||||
} catch (t: Throwable) {
|
||||
"<redaction failed; line withheld>"
|
||||
}
|
||||
}
|
||||
|
||||
/** [src]'s lines through [redactForShare], to [out]: the pass every text file in a shared zip gets. */
|
||||
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/** `<redacted:account>`, inside [value]'s quotes when it had them. */
|
||||
private fun quoted(value: String): String {
|
||||
val q = value.first().takeIf { it == '"' || it == '\'' }?.toString().orEmpty()
|
||||
return "$q<redacted:account>$q"
|
||||
}
|
||||
|
||||
/**
|
||||
* A line on its way through [redact]. Each rule runs only on a line holding what it cannot match
|
||||
* without - a word, enough separators, a long enough run: most of a Steam log is lines no rule
|
||||
* touches, and a session's logs went through every rule at about a megabyte a second. A line with anything but ASCII in it goes through
|
||||
* every case-insensitive rule, since Android's regex folds case more widely than lowercase().
|
||||
*/
|
||||
private class Line(var text: String) {
|
||||
private val unfolded = text.any { it.code >= 0x80 }
|
||||
private var lower: String? = null
|
||||
|
||||
fun has(vararg words: String): Boolean {
|
||||
if (unfolded) return true
|
||||
val folded = lower ?: text.lowercase().also { lower = it }
|
||||
return words.any { folded.contains(it) }
|
||||
}
|
||||
|
||||
fun set(next: String) {
|
||||
if (next != text) {
|
||||
text = next
|
||||
lower = null
|
||||
}
|
||||
}
|
||||
|
||||
fun holds(c: Char, times: Int): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (ch == c && ++n >= times) return true
|
||||
return false
|
||||
}
|
||||
|
||||
inline fun hasRun(length: Int, inRun: (Char) -> Boolean): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (!inRun(ch)) n = 0 else if (++n >= length) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||
|
||||
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
|
||||
fun redact(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
var out = line
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
|
||||
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
|
||||
out = WEBAPI_KEY.replace(out, "<redacted:key>")
|
||||
val out = Line(line)
|
||||
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
|
||||
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
|
||||
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
|
||||
val value = it.groupValues[2]
|
||||
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
|
||||
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
|
||||
})
|
||||
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
|
||||
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
|
||||
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
|
||||
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
|
||||
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
|
||||
}
|
||||
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
|
||||
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
|
||||
// Mask, not delete: the last four digits let a reader correlate lines to one account.
|
||||
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
|
||||
out = STEAMID3.replace(out) { m ->
|
||||
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
|
||||
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
|
||||
val id = m.groupValues[1]
|
||||
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
|
||||
})
|
||||
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
|
||||
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
|
||||
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if (out.has("account", "user")) {
|
||||
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
}
|
||||
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
|
||||
for (r in own) out = r.replace(out, "<redacted:ip>")
|
||||
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
for (r in accounts) out = r.replace(out, "<redacted:account>")
|
||||
out = EMAIL.replace(out, "<redacted:email>")
|
||||
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
|
||||
out = LONG_TOKEN.replace(out, "<redacted:token>")
|
||||
out
|
||||
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
|
||||
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
|
||||
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
|
||||
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
|
||||
}
|
||||
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
|
||||
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
|
||||
}
|
||||
out.text
|
||||
} catch (t: Throwable) {
|
||||
// A log line is never worth crashing a session over, but an unscrubbed one must not
|
||||
// reach the file either.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* The one set of rules that keeps URLs and tokens out of anything the app writes or shares: the
|
||||
* stores' log ([com.droiddeck.launcher.stores.StoreLog.redactLine]) and the last pass over every
|
||||
* text file in a shared log zip ([LogRedactor.redactForShare]).
|
||||
*
|
||||
* - Every URL loses its query, fragment and `user:password@`. [Urls.SHORT] then keeps only the host
|
||||
* and a plain first path segment (the stores' lines, where a GOG secure link carries its token in
|
||||
* the path); [Urls.KEEP_PATH] keeps the path but blanks any segment that carries a token.
|
||||
* - Outside URLs, the values of token-like keys (`token`, `__token__`, `f_token`, `hdnts`, access /
|
||||
* refresh / id tokens; exchange and authorization codes of four characters or more, and a bare
|
||||
* `code=` where it is one: in a query string or on a line with OAuth keys such as `client_id`,
|
||||
* `redirect_uri` or `state`, never an 8-hex exception code like Proton's `code=c0000005`) and of
|
||||
* Authorization / Cookie headers are blanked with [mark].
|
||||
*
|
||||
* A line already scrubbed comes out unchanged.
|
||||
*/
|
||||
object SecretScrub {
|
||||
enum class Urls { SHORT, KEEP_PATH }
|
||||
|
||||
private val URL = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
|
||||
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
|
||||
private val TOKEN_SEGMENT = Regex("""(?i)(token|hdnts|hmac|signature|sig|exp)=""")
|
||||
private val TOKEN = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'<>}]+""")
|
||||
/** Codes that are only ever OAuth grants. */
|
||||
private val GRANT_CODE = Regex("""(?i)(?<![A-Za-z0-9_])(exchange_code|authorization_?code)(["']?\s*=\s*["']?)[^\s&;,"'<>}]{4,}""")
|
||||
/** A bare `code=`: a grant only right after `?` / `&` (a query string) or beside OAuth keys ([OAUTH_CONTEXT]). */
|
||||
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code)(["']?\s*=\s*["']?)([^\s&;,"'<>}]{4,})""")
|
||||
private val OAUTH_CONTEXT = Regex("""(?i)(?<![A-Za-z0-9_])(client_id|client_secret|redirect_uri|state|grant_type|response_type|access_token|refresh_token|id_token)["']?\s*[=:]""")
|
||||
/** A Windows exception or status code (`code=c0000005`, `code=0x80000003`): never a grant. */
|
||||
private val STATUS_CODE = Regex("""(?i)(0x)?[0-9a-f]{8}""")
|
||||
private val HEADER = Regex("""(?i)\b(authorization|cookie|set-cookie)(\s*[:=]\s*).+""")
|
||||
|
||||
fun scrub(text: String, urls: Urls, mark: String): String {
|
||||
var out = URL.replace(text) { m -> if (urls == Urls.SHORT) short(m.value) else keepPath(m.value, mark) }
|
||||
out = TOKEN.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
out = GRANT_CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
val oauth = OAUTH_CONTEXT.containsMatchIn(text)
|
||||
val src = out
|
||||
out = CODE.replace(src) { m ->
|
||||
val inQuery = m.range.first > 0 && src[m.range.first - 1] in "?&"
|
||||
val grant = !STATUS_CODE.matches(m.groupValues[3]) && (oauth || inQuery)
|
||||
if (grant) m.groupValues[1] + m.groupValues[2] + mark else m.value
|
||||
}
|
||||
out = HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun split(url: String): Triple<String, String, String?> {
|
||||
val schemeEnd = url.indexOf("://")
|
||||
val rest = url.substring(schemeEnd + 3).substringBefore('#').substringBefore('?')
|
||||
val slash = rest.indexOf('/')
|
||||
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
|
||||
return Triple(url.substring(0, schemeEnd), authority, if (slash >= 0) rest.substring(slash + 1) else null)
|
||||
}
|
||||
|
||||
private fun short(url: String): String {
|
||||
val (scheme, authority, path) = split(url)
|
||||
if (path == null) return "$scheme://$authority"
|
||||
val first = path.substringBefore('/')
|
||||
if (first.isEmpty()) return "$scheme://$authority/"
|
||||
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
|
||||
return "$scheme://$authority/$shown" + if (path.length > first.length && shown != "…") "/…" else ""
|
||||
}
|
||||
|
||||
private fun keepPath(url: String, mark: String): String {
|
||||
val (scheme, authority, path) = split(url)
|
||||
val clean = path?.split('/')?.joinToString("/") { seg -> if (TOKEN_SEGMENT.containsMatchIn(seg)) mark else seg }
|
||||
return "$scheme://$authority" + (clean?.let { "/$it" } ?: "")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class FileManagerActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
|
||||
@@ -1020,7 +1020,8 @@ fun FileManagerScreen(
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
// The app's own logs folder: private, which this file manager (the app itself) can read.
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
|
||||
@@ -35,6 +35,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
// targetSdk 28: the old storage permission is exactly what lets us list the card.
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Games added from the Games tab's + rather than a Games folder: one picked .exe, or each game
|
||||
* "Add all games in this folder" found. Kept by the game's folder in `files/added-exes.json`,
|
||||
* which [AddedGames.scan] merges in. The exe chosen later, the shortcut appid, the edits and the
|
||||
* removal live where every added game keeps them ([com.droiddeck.launcher.session.SessionPrefs]);
|
||||
* this holds which folders are games and the exe they came with.
|
||||
*/
|
||||
object AddedExes {
|
||||
/** [picked]: the user chose [exe]; false = [GameExePicker]'s best guess, which a scan ranks again. */
|
||||
class Entry(val folder: String, val exe: String, val picked: Boolean = true)
|
||||
|
||||
private fun file(context: Context) = File(context.filesDir, "added-exes.json")
|
||||
|
||||
@Synchronized
|
||||
fun list(context: Context): List<Entry> = try {
|
||||
val array = JSONArray(file(context).takeIf { it.isFile }?.readText() ?: "[]")
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val folder = o.optString("folder"); val exe = o.optString("exe")
|
||||
if (folder.isEmpty() || exe.isEmpty()) null else Entry(folder, exe, o.optBoolean("picked", true))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Adds [entry], replacing one for the same folder. */
|
||||
@Synchronized
|
||||
fun put(context: Context, entry: Entry) = write(context, list(context).filter { it.folder != entry.folder } + entry)
|
||||
|
||||
@Synchronized
|
||||
fun drop(context: Context, folder: String) = write(context, list(context).filter { it.folder != folder })
|
||||
|
||||
private fun write(context: Context, entries: List<Entry>) {
|
||||
val array = JSONArray()
|
||||
entries.forEach { array.put(JSONObject().put("folder", it.folder).put("exe", it.exe).put("picked", it.picked)) }
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Artwork for an added game: what the user put in the game's folder first, else what Steam's
|
||||
* store has for the same title, fetched once into the app's cache. Four pieces, named the way
|
||||
* Artwork for an added game: what the user put in the game's folder first, else what Steam has
|
||||
* for it - by the Steam appid its files name, else by a store search on its title - else what
|
||||
* SteamGridDB has ([SteamGridDb]), fetched once into the app's cache. Four pieces, named the way
|
||||
* the client names them in its grid folder: the portrait capsule, the wide header, the hero
|
||||
* background and the logo; plus an icon pulled out of the game's own .exe. The session hands
|
||||
* the paths to the runtime's shortcuts writer, which copies them into the client's grid.
|
||||
@@ -28,13 +29,40 @@ object AddedGameArt {
|
||||
val any get() = portrait != null || header != null || hero != null || logo != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The pieces a user can choose, as the client takes them: [assetType] is its custom-artwork
|
||||
* number (null: the icon, set by path); [cacheName] the automatic file in the game's cache.
|
||||
*/
|
||||
enum class Slot(val key: String, val cacheName: String, val assetType: Int?) {
|
||||
COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
|
||||
}
|
||||
|
||||
/** Where a piece came from; [AUTO] = nothing found yet. */
|
||||
const val FOLDER = "folder"
|
||||
const val STEAM = "steam"
|
||||
const val SGDB = "sgdb"
|
||||
const val FILE = "file"
|
||||
const val EXE = "exe"
|
||||
const val AUTO = ""
|
||||
|
||||
/** A slot as it stands: the file shown and where it came from (one of [FOLDER].. [AUTO]); [chosen] = the user picked it. */
|
||||
class SlotState(val slot: Slot, val file: File?, val source: String, val chosen: Boolean)
|
||||
|
||||
/** One image the chooser offers: its group ([FOLDER], [STEAM], [SGDB]), a thumbnail and the full image (URL or path). */
|
||||
class Option(val group: String, val thumb: String, val full: String)
|
||||
|
||||
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
|
||||
|
||||
private fun chosen(cache: File, slot: Slot): File? =
|
||||
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
|
||||
|
||||
private val IMAGE = listOf("png", "jpg", "jpeg")
|
||||
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
|
||||
private val HEADER = listOf("header", "banner", "library_header", "wide")
|
||||
private val HERO = listOf("hero", "background", "library_hero")
|
||||
private val LOGO = listOf("logo")
|
||||
|
||||
private fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
|
||||
internal fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
|
||||
|
||||
/** An image in the folder (or its `art` subfolder) under one of the given names, any image extension. */
|
||||
private fun find(folder: File, names: List<String>): File? {
|
||||
@@ -58,20 +86,111 @@ object AddedGameArt {
|
||||
)
|
||||
}
|
||||
|
||||
/** The folder's art, else the cached Steam art, without touching the network. */
|
||||
/** The user's choice for each piece, else the folder's art, else the cached art; no network. */
|
||||
fun resolve(context: Context, game: AddedGames.Game): Art {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
|
||||
return Art(
|
||||
portrait = own.portrait ?: cached("p.jpg"),
|
||||
header = own.header ?: cached("header.jpg"),
|
||||
hero = own.hero ?: cached("hero.jpg"),
|
||||
logo = own.logo ?: cached("logo.png"),
|
||||
icon = own.icon ?: exeIcon(context, game),
|
||||
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
|
||||
header = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
|
||||
logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"),
|
||||
icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game),
|
||||
)
|
||||
}
|
||||
|
||||
/** Each choosable piece with its file and where it came from, for the game's editor; no network. */
|
||||
fun describe(context: Context, game: AddedGames.Game): List<SlotState> {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
fun cached(name: String): Pair<File, String>? = File(cache, name).takeIf { it.isFile }?.let { f ->
|
||||
f to (File(cache, "$name.src").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: STEAM)
|
||||
}
|
||||
return Slot.values().map { slot ->
|
||||
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
|
||||
val auto: Pair<File, String>? = when (slot) {
|
||||
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
|
||||
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
|
||||
Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png")
|
||||
Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB }
|
||||
?: exeIcon(context, game)?.let { it to EXE }
|
||||
}
|
||||
SlotState(slot, auto?.first, auto?.second ?: AUTO, false)
|
||||
}
|
||||
}
|
||||
|
||||
private val CHOOSABLE = setOf("png", "jpg", "jpeg", "webp")
|
||||
|
||||
/** Images in the game's folder (and its art folders) that could stand for a piece. */
|
||||
internal fun folderImages(folder: File): List<File> =
|
||||
listOf(folder, File(folder, "art"), File(folder, ".art")).flatMap { dir ->
|
||||
dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty()
|
||||
}.take(12)
|
||||
|
||||
/**
|
||||
* What the chooser offers for [slot]: the game folder's images, Steam's official piece (when the
|
||||
* game has a Steam appid and Steam has the piece), and SteamGridDB's best few (with a key).
|
||||
* Blocking: it asks the network.
|
||||
*/
|
||||
fun options(context: Context, game: AddedGames.Game, slot: Slot): List<Option> {
|
||||
val out = ArrayList<Option>()
|
||||
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
|
||||
val cache = cacheDir(context, game)
|
||||
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
|
||||
if (steamId != null && remote != null) {
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
|
||||
if (url != null) out.add(Option(STEAM, url, url))
|
||||
}
|
||||
val key = SteamGridDb.key(context)
|
||||
if (key.isNotEmpty()) {
|
||||
cache.mkdirs()
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull()
|
||||
if (gridId != null) runCatching { SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5) }.getOrNull()
|
||||
?.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Sets [slot] to [option] (a file is copied, a URL fetched) and records where it came from. False when it could not be had. Blocking. */
|
||||
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
|
||||
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
|
||||
bytes ?: return false
|
||||
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
|
||||
val cache = cacheDir(context, game).apply { mkdirs() }
|
||||
chosen(cache, slot)?.delete()
|
||||
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
|
||||
val tmp = File(cache, dst.name + ".tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (!tmp.renameTo(dst)) return false
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Where the user's choice for [slot] came from and which image it was ("" both when automatic). */
|
||||
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
|
||||
val value = SessionPrefs.addedGameArtSource(context, game.folder.path, slot.key)
|
||||
return value.substringBefore('|') to value.substringAfter('|', "")
|
||||
}
|
||||
|
||||
/** [slot] back to what is found by itself. */
|
||||
fun reset(context: Context, game: AddedGames.Game, slot: Slot) {
|
||||
chosen(cacheDir(context, game), slot)?.delete()
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, "")
|
||||
}
|
||||
|
||||
private fun exists(url: String): Boolean = runCatching {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.requestMethod = "HEAD"
|
||||
c.connectTimeout = 8_000
|
||||
c.readTimeout = 8_000
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
|
||||
try { c.responseCode == 200 } finally { c.disconnect() }
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** The first icon inside the game's .exe, kept as a PNG beside the cached art. */
|
||||
private fun exeIcon(context: Context, game: AddedGames.Game): File? {
|
||||
val cache = cacheDir(context, game)
|
||||
@@ -88,58 +207,91 @@ object AddedGameArt {
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks the games up on Steam's store and fetches the art that is missing, once per game
|
||||
* (a miss is retried after a week). Blocking: run it off the main thread. Returns true when
|
||||
* anything new arrived.
|
||||
* Fetches the art that is missing: Steam first (the appid the game's files name, else a store
|
||||
* search on its title whose result carries the same name), then SteamGridDB when a key is
|
||||
* set. Each lookup runs once per game; a miss is retried after a week. Blocking: run it off
|
||||
* the main thread. Returns true when anything new arrived.
|
||||
*/
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
|
||||
if (!SessionPrefs.addedGamesArt(context)) return false
|
||||
val sgdbKey = SteamGridDb.key(context)
|
||||
var changed = false
|
||||
for (game in games) {
|
||||
if (local(game).portrait != null) continue
|
||||
val cache = cacheDir(context, game)
|
||||
val lookup = File(cache, "steam-appid")
|
||||
if (File(cache, "p.jpg").isFile) continue
|
||||
if (lookup.isFile && (lookup.readText().trim() != "none" || System.currentTimeMillis() - lookup.lastModified() < RETRY_AFTER_MS)) {
|
||||
val id = lookup.readText().trim()
|
||||
if (id == "none") continue
|
||||
if (download(id, cache)) changed = true
|
||||
continue
|
||||
}
|
||||
cache.mkdirs()
|
||||
val id = runCatching { search(game.name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
|
||||
if (id == null) { lookup.writeText("none"); continue }
|
||||
lookup.writeText(id)
|
||||
Log.i(TAG, "${game.name}: Steam app $id")
|
||||
if (download(id, cache)) changed = true
|
||||
val match = steamMatch(context, game) { name ->
|
||||
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
|
||||
}
|
||||
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
|
||||
val id = match.appId?.toString()
|
||||
if (id != null && download(id, cache)) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull() ?: continue
|
||||
if (runCatching { SteamGridDb.download(sgdbKey, gridId, cache) }.getOrDefault(false)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* The game's Steam app, the one record its Windows components use too ([SteamMatch]): what its
|
||||
* files name, else a store search on its title ([search]) whose result has the same name.
|
||||
*/
|
||||
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
|
||||
val match = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cacheDir(context, game), "steam-appid"), search = search)
|
||||
match.appId?.let { Log.i(TAG, "${game.name}: Steam app $it (${match.certainty.name.lowercase()})") }
|
||||
return match
|
||||
}
|
||||
|
||||
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
|
||||
private fun remembered(marker: File, find: () -> String?): String? {
|
||||
if (marker.isFile) {
|
||||
val value = marker.readText().trim()
|
||||
if (value != "none") return value
|
||||
if (System.currentTimeMillis() - marker.lastModified() < RETRY_AFTER_MS) return null
|
||||
}
|
||||
val found = find()
|
||||
marker.writeText(found ?: "none")
|
||||
return found
|
||||
}
|
||||
|
||||
private fun normalize(s: String) = s.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
|
||||
/** The Steam appid whose store name matches the folder name best, or null for no match. */
|
||||
private val EDITION = Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b")
|
||||
|
||||
/**
|
||||
* How well a result's name [got] matches the game's [name]: 3 the same, 2 the same once
|
||||
* edition words are dropped, 1 one starts with the other; 0 no match. Nothing is taken for
|
||||
* being the first result.
|
||||
*/
|
||||
internal fun nameMatch(name: String, got: String): Int {
|
||||
val want = normalize(name)
|
||||
val wantShort = normalize(name.replace(EDITION, " ").trim().ifEmpty { name })
|
||||
val have = normalize(got)
|
||||
if (have.isEmpty() || wantShort.isEmpty()) return 0
|
||||
return when {
|
||||
have == want -> 3
|
||||
have == wantShort -> 2
|
||||
have.startsWith(wantShort) || wantShort.startsWith(have) -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The id of the store result whose name matches [name] best, or null when none does. */
|
||||
internal fun pickStoreResult(name: String, items: List<Pair<String, String>>): String? =
|
||||
items.map { (id, got) -> id to nameMatch(name, got) }
|
||||
.filter { it.second > 0 && it.first.isNotEmpty() }
|
||||
.maxByOrNull { it.second }?.first
|
||||
|
||||
/** The Steam appid whose store name matches [name], or null for no match. */
|
||||
private fun search(name: String): String? {
|
||||
val term = name.replace(Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b"), " ").trim().ifEmpty { name }
|
||||
val term = name.replace(EDITION, " ").trim().ifEmpty { name }
|
||||
val json = get(STORE_SEARCH + URLEncoder.encode(term, "UTF-8"))?.toString(Charsets.UTF_8) ?: return null
|
||||
val items = JSONObject(json).optJSONArray("items") ?: return null
|
||||
val want = normalize(name)
|
||||
val wantShort = normalize(term)
|
||||
var best: String? = null
|
||||
var bestScore = 0
|
||||
for (i in 0 until items.length()) {
|
||||
val item = items.getJSONObject(i)
|
||||
val got = normalize(item.optString("name"))
|
||||
val score = when {
|
||||
got == want -> 4
|
||||
got == wantShort -> 3
|
||||
got.startsWith(wantShort) || wantShort.startsWith(got) -> 2
|
||||
i == 0 -> 1
|
||||
else -> 0
|
||||
}
|
||||
if (score > bestScore) { bestScore = score; best = item.optString("id") }
|
||||
}
|
||||
return best?.takeIf { it.isNotEmpty() && bestScore >= 1 }
|
||||
return pickStoreResult(name, (0 until items.length()).map { items.getJSONObject(it) }.map { it.optString("id") to it.optString("name") })
|
||||
}
|
||||
|
||||
private fun download(id: String, cache: File): Boolean {
|
||||
@@ -149,15 +301,73 @@ object AddedGameArt {
|
||||
)) {
|
||||
val dst = File(cache, localName)
|
||||
if (dst.isFile) continue
|
||||
val bytes = get("$CDN$id/$remote") ?: continue
|
||||
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
|
||||
val tmp = File(cache, "$localName.tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (tmp.renameTo(dst)) got = true
|
||||
if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) }
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
private fun get(url: String): ByteArray? {
|
||||
/**
|
||||
* Newer Steam games keep their art under hashed paths (store_item_assets/steam/apps/<id>/<hash>/…),
|
||||
* where the fixed CDN names above 404. The store's item API names those paths; once per appid.
|
||||
*/
|
||||
private fun steamAssetUrl(id: String, remote: String): String? {
|
||||
val assets = steamAssets.getOrPut(id) {
|
||||
runCatching {
|
||||
val input = """{"ids":[{"appid":$id}],"context":{"language":"english","country_code":"US"},"data_request":{"include_assets":true}}"""
|
||||
val json = get("https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + URLEncoder.encode(input, "UTF-8"))
|
||||
?.toString(Charsets.UTF_8) ?: return@runCatching emptyMap()
|
||||
val a = JSONObject(json).optJSONObject("response")?.optJSONArray("store_items")?.optJSONObject(0)?.optJSONObject("assets")
|
||||
?: return@runCatching emptyMap()
|
||||
val format = a.optString("asset_url_format").takeIf { it.contains("\${FILENAME}") } ?: return@runCatching emptyMap()
|
||||
fun url(vararg keys: String) = keys.firstNotNullOfOrNull { k -> a.optString(k).takeIf { it.isNotEmpty() } }
|
||||
?.let { "https://shared.akamai.steamstatic.com/store_item_assets/" + format.replace("\${FILENAME}", it) }
|
||||
mapOf(
|
||||
"library_600x900.jpg" to url("library_capsule_2x", "library_capsule"),
|
||||
"header.jpg" to url("header"),
|
||||
"library_hero.jpg" to url("library_hero_2x", "library_hero"),
|
||||
).filterValues { it != null }.mapValues { it.value!! }
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
return assets[remote]
|
||||
}
|
||||
|
||||
private val steamAssets = java.util.concurrent.ConcurrentHashMap<String, Map<String, String>>()
|
||||
|
||||
/**
|
||||
* When nothing else gave a cover (or header), one made from the exe's icon: the icon centred on
|
||||
* its own colour, darkened toward the bottom, so the game still has a face in the list and in
|
||||
* Steam. Made once per game; real art found later takes over because it is looked at first.
|
||||
*/
|
||||
private fun exeArt(context: Context, game: AddedGames.Game, cover: Boolean): File? {
|
||||
val cache = cacheDir(context, game)
|
||||
val out = File(cache, if (cover) "p-exe.png" else "header-exe.png")
|
||||
if (out.isFile) return out
|
||||
val icon = exeIcon(context, game) ?: return null
|
||||
val src = android.graphics.BitmapFactory.decodeFile(icon.path) ?: return null
|
||||
val (w, h) = if (cover) 600 to 900 else 920 to 430
|
||||
val tint = android.graphics.Bitmap.createScaledBitmap(src, 1, 1, true).getPixel(0, 0)
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bmp)
|
||||
fun shade(c: Int, f: Float) = android.graphics.Color.rgb(
|
||||
(android.graphics.Color.red(c) * f).toInt(), (android.graphics.Color.green(c) * f).toInt(), (android.graphics.Color.blue(c) * f).toInt(),
|
||||
)
|
||||
canvas.drawPaint(android.graphics.Paint().apply {
|
||||
shader = android.graphics.LinearGradient(0f, 0f, 0f, h.toFloat(), shade(tint, 0.7f), shade(tint, 0.15f), android.graphics.Shader.TileMode.CLAMP)
|
||||
})
|
||||
val side = (minOf(w, h) * 0.5f).toInt()
|
||||
val left = (w - side) / 2f
|
||||
val top = (h - side) / 2f
|
||||
canvas.drawBitmap(src, null, android.graphics.RectF(left, top, left + side, top + side), android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG))
|
||||
cache.mkdirs()
|
||||
val tmp = File(cache, "${out.name}.tmp")
|
||||
tmp.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
return if (tmp.renameTo(out)) out else null
|
||||
}
|
||||
|
||||
internal fun get(url: String): ByteArray? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
c.readTimeout = 15_000
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.SteamLiveShortcuts
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The game editor's writes for an added game, Steam's non-Steam Properties in the app: name,
|
||||
* target, Start in, launch options and art. Each goes through the same shortcut - the listing the
|
||||
* shortcuts writer reads, and the running client - on the appid the game already has, so an edit
|
||||
* never makes a second shortcut. Each call is quick (the game alone, [AddedGames.single]); the
|
||||
* client and the listing follow on [AddedGamesPublisher], in order. Call off the main thread.
|
||||
*/
|
||||
object AddedGameEdits {
|
||||
/** The added game in [folder] as it stands now, or null when it is not listed. */
|
||||
fun game(context: Context, folder: String): AddedGames.Game? = AddedGames.single(context, folder)
|
||||
|
||||
/** The client told ([live]), then the listing rewritten, on [AddedGamesPublisher]. */
|
||||
private fun publish(context: Context, live: () -> Unit) = AddedGamesPublisher.later(context, live)
|
||||
|
||||
/** [name] as the game's name; "" goes back to the name found by itself. */
|
||||
fun setName(context: Context, folder: String, name: String): AddedGames.Game? {
|
||||
SessionPrefs.setAddedGameName(context, folder, name)
|
||||
return apply(context, folder)
|
||||
}
|
||||
|
||||
/** [exe] as the game's target; false when the session cannot see it. */
|
||||
fun setExe(context: Context, folder: String, exe: File): Boolean {
|
||||
if (!exe.isFile || AddedGames.guestPath(context, exe) == null) return false
|
||||
SessionPrefs.setAddedGameExe(context, folder, exe.path)
|
||||
apply(context, folder)
|
||||
return true
|
||||
}
|
||||
|
||||
/** [dir] (a host folder) as the game's Start in; "" = the exe's own folder. False when the session cannot see it. */
|
||||
fun setStartIn(context: Context, folder: String, dir: String): Boolean {
|
||||
val guest = if (dir.isBlank()) "" else AddedGames.guestPath(context, File(dir.trim())) ?: return false
|
||||
SessionPrefs.setAddedGameStartIn(context, folder, guest)
|
||||
apply(context, folder)
|
||||
return true
|
||||
}
|
||||
|
||||
fun setLaunchOptions(context: Context, folder: String, options: String): AddedGames.Game? {
|
||||
SessionPrefs.setAddedGameLaunch(context, folder, options)
|
||||
return apply(context, folder)
|
||||
}
|
||||
|
||||
/** [slot] set to [option], then handed to the shortcut. False when the image could not be had. */
|
||||
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
|
||||
val game = game(context, folder) ?: return false
|
||||
if (!AddedGameArt.choose(context, game, slot, option)) return false
|
||||
applyArt(context, game, slot)
|
||||
return true
|
||||
}
|
||||
|
||||
/** [slot] back to automatic, then handed to the shortcut. */
|
||||
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
|
||||
val game = game(context, folder) ?: return
|
||||
AddedGameArt.reset(context, game, slot)
|
||||
applyArt(context, game, slot)
|
||||
}
|
||||
|
||||
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
|
||||
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
|
||||
publish(context) {
|
||||
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
|
||||
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply(context: Context, folder: String): AddedGames.Game? {
|
||||
val game = game(context, folder)
|
||||
if (game != null) publish(context) { SteamLiveShortcuts.update(context, game) }
|
||||
return game
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
@@ -16,33 +18,42 @@ object AddedGames {
|
||||
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
val folder: File, val name: String,
|
||||
/** The game's own .exe: what the icon is read from and what the settings page lists. */
|
||||
val exe: File,
|
||||
/** The guest path the shortcut runs: [exe], or a store install's launcher .bat in front of it. */
|
||||
val guestExe: String, val guestDir: String,
|
||||
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
|
||||
val appId: Long,
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
|
||||
val source: String = Library.ADDED,
|
||||
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
|
||||
val storeId: String? = null,
|
||||
/** The exe is the picker's guess and nothing clearly won ([GameExePicker.Ranking.uncertain]). */
|
||||
val exeUncertain: Boolean = false,
|
||||
/** Steam's launch options for the shortcut; "" for none. */
|
||||
val launchOptions: String = "",
|
||||
/** Added from the Games tab (an entry of its own), not found in a Games folder. */
|
||||
val single: Boolean = false,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
|
||||
/** One Games folder and where the session sees it. */
|
||||
/** A Games folder an earlier build had, and where the session sees it. */
|
||||
class Root(val host: File, val guest: String)
|
||||
|
||||
/**
|
||||
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
|
||||
* the host path when two chosen folders share a name (so a folder's guest path, and with it
|
||||
* every shortcut's appid, does not change when another folder is added or removed).
|
||||
* The Games folders earlier builds had, bound into the session at the guest paths they had
|
||||
* then (/root/Games/<name>), so the games in them (entries now, [GamesFolderMigration]) keep
|
||||
* their paths and shortcuts. Nothing adds one any more.
|
||||
*/
|
||||
fun roots(context: Context): List<Root> {
|
||||
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
Root(host, "$GUEST_DIR/$guestName")
|
||||
}
|
||||
GamesFolderMigration.run(context)
|
||||
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
|
||||
}
|
||||
|
||||
private val SKIP = Regex(
|
||||
@@ -66,45 +77,296 @@ object AddedGames {
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
// A store root on a card has a bind of its own, before the library rule: the same folder
|
||||
// keeps the same guest path (and appid) whether or not that card is also the Steam library.
|
||||
StoreInstallRoot.externalGuestPath(context, host)?.let { return it }
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
// Inside the runtime's own tree (store installs on internal storage live at
|
||||
// root/Games/Stores): the tree is the session's / and needs no bind.
|
||||
val runtime = LinuxRuntime.rootDir(context).absolutePath
|
||||
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
|
||||
// Internal storage, bound at /root/Storage in every session: an exe picked anywhere on it.
|
||||
val storage = android.os.Environment.getExternalStorageDirectory().absolutePath
|
||||
if (path.startsWith("$storage/")) return "/root/Storage/" + path.removePrefix("$storage/")
|
||||
return null
|
||||
}
|
||||
|
||||
/** The .exe files a game folder offers, best first. */
|
||||
fun candidates(folder: File): List<File> {
|
||||
val exes = ArrayList<File>()
|
||||
val roots = listOf(folder) + (folder.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList())
|
||||
for (dir in roots) {
|
||||
dir.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(f.name) }
|
||||
?.let { exes.addAll(it) }
|
||||
}
|
||||
val key = folder.name.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
return exes.sortedWith(
|
||||
compareByDescending<File> { it.parentFile == folder }
|
||||
.thenByDescending { it.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "").let { n -> n == key || key.startsWith(n) || n.startsWith(key) } }
|
||||
.thenByDescending { it.length() },
|
||||
/** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */
|
||||
fun candidates(folder: File): List<File> = rank(folder).exes
|
||||
|
||||
/** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */
|
||||
fun rank(folder: File): GameExePicker.Ranking {
|
||||
val key = "${folder.path}|${folder.lastModified()}"
|
||||
val now = System.currentTimeMillis()
|
||||
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
|
||||
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
|
||||
val declared = declaredExes(folder)
|
||||
val preferred = declared.map { it.path }.toSet()
|
||||
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
|
||||
declared + guessed.exes.filter { it.path !in preferred },
|
||||
List(declared.size) { Int.MAX_VALUE } + guessed.exes.zip(guessed.scores)
|
||||
.filter { it.first.path !in preferred }.map { it.second },
|
||||
uncertain = false,
|
||||
)
|
||||
if (ranked.size > 256) ranked.clear()
|
||||
ranked[key] = now to result
|
||||
return result
|
||||
}
|
||||
|
||||
/** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */
|
||||
private val ranked = java.util.concurrent.ConcurrentHashMap<String, Pair<Long, GameExePicker.Ranking>>()
|
||||
private const val RANK_FRESH_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Names identified from an exe (Steam manifest, GOG info, version resource), by the exe's path,
|
||||
* size and time and its game folder's time (a GOG info file dropped in changes the latter).
|
||||
*/
|
||||
private val identified = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* The game's own title for [exe] when the disk names one ([GameIdentifier]: a Steam manifest,
|
||||
* a GOG info file, the exe's version resource), else null (the folder name stands).
|
||||
*/
|
||||
internal fun identifiedName(folder: File, exe: File): String? {
|
||||
val key = "${exe.path}|${exe.length()}|${exe.lastModified()}|${folder.lastModified()}"
|
||||
identified[key]?.let { return it.ifEmpty { null } }
|
||||
val id = runCatching { GameIdentifier.identify(exe) }.getOrNull()
|
||||
val found = id?.name?.takeIf {
|
||||
id.nameSource in setOf(GameIdentifier.Source.STEAM_MANIFEST_ACF, GameIdentifier.Source.GOG_INFO, GameIdentifier.Source.PE_VERSION)
|
||||
}
|
||||
val name = found?.let { if (id?.nameSource == GameIdentifier.Source.PE_VERSION) betterSpelling(underscoresToSpaces(it), folder.name) else it }
|
||||
identified[key] = name.orEmpty()
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* The exe's version resource often spells the title the way a programmer typed it ("Insane2")
|
||||
* while the folder has the real spacing ("Insane 2"). When both spell the same letters and
|
||||
* digits, the one with more word breaks wins; a tie keeps mixed case over all one case
|
||||
* ("DiRT 3" over "dirt 3"), else the exe's spelling. Different titles keep the exe's.
|
||||
*/
|
||||
/** "Watch_Dogs" → "Watch Dogs": an underscore in a version-resource title is a programmer's space. */
|
||||
internal fun underscoresToSpaces(name: String): String =
|
||||
name.replace('_', ' ').replace(Regex("\\s+"), " ").trim().ifEmpty { name }
|
||||
|
||||
internal fun betterSpelling(peName: String, folderName: String): String {
|
||||
fun key(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
if (key(peName) != key(folderName) || folderName.isBlank()) return peName
|
||||
fun breaks(s: String) = s.count { !it.isLetterOrDigit() }
|
||||
fun mixed(s: String) = s.any { it.isUpperCase() } && s.any { it.isLowerCase() }
|
||||
return when {
|
||||
breaks(folderName) > breaks(peName) -> folderName.trim()
|
||||
breaks(folderName) < breaks(peName) -> peName
|
||||
mixed(folderName) && !mixed(peName) -> folderName.trim()
|
||||
else -> peName
|
||||
}
|
||||
}
|
||||
/** The executables MicrosoftGame.config or appxmanifest.xml declares that exist inside [folder]. */
|
||||
internal fun declaredExes(folder: File): List<File> {
|
||||
val names = DECLARED.flatMap { (file, pattern) ->
|
||||
runCatching { pattern.findAll(File(folder, file).readText()).map { it.groupValues[1] }.toList() }.getOrDefault(emptyList())
|
||||
}
|
||||
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
|
||||
return names.asSequence()
|
||||
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
|
||||
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
|
||||
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
|
||||
.distinctBy { it.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private val DECLARED = listOf(
|
||||
"MicrosoftGame.config" to Regex("<Executable\\b[^>]*?\\bName\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
|
||||
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
GamesFolderMigration.run(context)
|
||||
val folders = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
val removed = SessionPrefs.removedAddedGames(context).toSet()
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
// Removed from the list by the user (the folder stays on disk); restored from the settings page.
|
||||
if (folder.path in removed) continue
|
||||
// The store install root sits inside the Games storage; its games are scanned below, per store.
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
// Games added as one picked exe (the Games tab's +), by that exe's folder.
|
||||
for (entry in AddedExes.list(context)) {
|
||||
val folder = File(entry.folder)
|
||||
if (folder.path in removed || !folder.isDirectory) continue
|
||||
scanGame(context, folder, out, single = entry)
|
||||
}
|
||||
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
|
||||
// An unfinished one (sidecar still `installing`, or none at all) is neither a game nor Custom.
|
||||
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* The game in [folderPath] alone, built by the same rules as [scan] (store sidecar, an entry
|
||||
* from the Games tab, the chosen exe, the edits) without walking every other game: what the
|
||||
* editor needs for one game. Null when that folder is not a game, or was removed.
|
||||
*/
|
||||
fun single(context: Context, folderPath: String): Game? {
|
||||
val folder = File(folderPath)
|
||||
if (!folder.isDirectory || folderPath in SessionPrefs.removedAddedGames(context)) return null
|
||||
val out = ArrayList<Game>(1)
|
||||
scanGame(context, folder, out, single = AddedExes.list(context).firstOrNull { it.folder == folderPath })
|
||||
return out.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listing the added game in [folder] at once: marked removed and dropped from the
|
||||
* library cache; the running client loses its shortcut ([appId], else the one stored for the
|
||||
* folder) and the listing is rewritten afterwards ([AddedGamesPublisher]). Nothing on disk is
|
||||
* deleted. Quick; call off the main thread (preferences and a small file).
|
||||
*/
|
||||
fun remove(context: Context, folder: File, appId: Long? = null, name: String = folder.name) {
|
||||
val app = context.applicationContext
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
val id = appId ?: SessionPrefs.addedGameStoredAppId(app, folder.path)
|
||||
SessionPrefs.setAddedGameRemoved(app, folder.path, true)
|
||||
LibraryCache.save(app, LibraryCache.load(app).filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder.path })
|
||||
Log.i(TAG, "games: remove $name took ${android.os.SystemClock.elapsedRealtime() - started}ms")
|
||||
AddedGamesPublisher.later(app) { if (id != null) com.droiddeck.launcher.stores.SteamLiveShortcuts.remove(app, id) }
|
||||
}
|
||||
|
||||
/** What [addExe] did with a picked exe. */
|
||||
sealed class AddResult {
|
||||
/** A new game, or the existing one the exe already is, or the listed game whose exe it now is. */
|
||||
class Added(val game: Game) : AddResult()
|
||||
class Existing(val game: Game) : AddResult()
|
||||
class Switched(val game: Game) : AddResult()
|
||||
/** The session cannot see the exe's path. */
|
||||
object Unreachable : AddResult()
|
||||
object NotFound : AddResult()
|
||||
}
|
||||
|
||||
private fun canonical(f: File): String = runCatching { f.canonicalPath }.getOrDefault(f.absolutePath)
|
||||
|
||||
private fun inside(path: String, folder: String) = path.startsWith(folder.trimEnd('/') + "/")
|
||||
|
||||
/**
|
||||
* The game folder [exe] belongs to, without a scan: an entry from the Games tab (Games folders
|
||||
* of earlier builds included), a removed game, a store install, or the first-level folder under
|
||||
* the Steam library that holds it. Null when it is in none.
|
||||
*/
|
||||
private fun ownerFolder(context: Context, exe: File): File? {
|
||||
GamesFolderMigration.run(context)
|
||||
val path = canonical(exe)
|
||||
fun holds(f: File) = inside(path, canonical(f))
|
||||
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
|
||||
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
|
||||
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
|
||||
// Games folders of earlier builds are entries now, found above.
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
)
|
||||
for (dir in dirs) {
|
||||
val base = canonical(dir)
|
||||
if (!inside(path, base)) continue
|
||||
val first = path.removePrefix("$base/").substringBefore('/')
|
||||
File(dir, first).takeIf { it.isDirectory && first != "steamapps" }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the game [exe] is: the game itself when it is listed already, that game's exe switched
|
||||
* when [exe] sits in a listed (or removed) game's folder, else a new game for [exe]'s folder.
|
||||
* Only that one game is read; Steam (the running client, then the listing) follows on
|
||||
* [AddedGamesPublisher]. Nothing is added when the session cannot see the path.
|
||||
*/
|
||||
fun addExe(context: Context, exe: File): AddResult {
|
||||
val app = context.applicationContext
|
||||
if (!exe.isFile) return AddResult.NotFound
|
||||
val path = canonical(exe)
|
||||
val owner = ownerFolder(app, exe)
|
||||
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
|
||||
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
|
||||
}
|
||||
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
|
||||
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
|
||||
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
|
||||
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
|
||||
if (owner == null) AddedExes.put(app, AddedExes.Entry(folder.path, exe.path))
|
||||
val game = single(app, folder.path) ?: return AddResult.NotFound
|
||||
AddedGamesPublisher.later(app) { com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, listOf(game)) { true } }
|
||||
Log.i(TAG, "added ${game.name} from its exe")
|
||||
return if (owner == null) AddResult.Added(game) else AddResult.Switched(game)
|
||||
}
|
||||
|
||||
/** What [addFolder] did: the games it added, and how many of the folder's games were listed already. */
|
||||
class FolderResult(val added: List<Game>, val already: Int)
|
||||
|
||||
/**
|
||||
* "Add all games in this folder": every subfolder of [dir] with an .exe becomes a game with
|
||||
* [GameExePicker]'s best one (marked uncertain where nothing clearly won), as an entry of its
|
||||
* own, not a Games folder. A subfolder that is, holds or sits in a listed game counts as
|
||||
* already there; a removed one comes back. Steam follows once, on [AddedGamesPublisher]. Blocking.
|
||||
*/
|
||||
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
|
||||
val app = context.applicationContext
|
||||
val removed = SessionPrefs.removedAddedGames(app).toSet()
|
||||
val listed = listedFolders(app, removed)
|
||||
var already = 0
|
||||
val fresh = ArrayList<String>()
|
||||
val added = ArrayList<Game>()
|
||||
for (sub in dir.listFiles { f -> f.isDirectory && !f.name.startsWith(".") }?.sortedBy { it.name.lowercase() }.orEmpty()) {
|
||||
val path = canonical(sub)
|
||||
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
|
||||
onChecking(sub.name)
|
||||
val best = rank(sub).exes.firstOrNull() ?: continue
|
||||
if (guestPath(app, best) == null) continue
|
||||
if (sub.path in removed) SessionPrefs.setAddedGameRemoved(app, sub.path, false)
|
||||
AddedExes.put(app, AddedExes.Entry(sub.path, best.path, picked = false))
|
||||
fresh.add(sub.path)
|
||||
single(app, sub.path)?.let { added.add(it); onGame(it) }
|
||||
}
|
||||
onChecking(null)
|
||||
if (fresh.isEmpty()) return FolderResult(emptyList(), already)
|
||||
AddedGamesPublisher.later(app) { com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, added) { true } }
|
||||
Log.i(TAG, "added ${added.size} game(s) from ${dir.name}, $already already listed")
|
||||
return FolderResult(added, already)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game folders already listed, without building any game: the first-level folders of the
|
||||
* Steam library (as [scan] walks them), the Games tab's entries (Games folders of earlier builds
|
||||
* included) and the store installs. Canonical paths; removed games are left out.
|
||||
*/
|
||||
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
GamesFolderMigration.run(context)
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
for (dir in dirs.distinctBy { it.absolutePath }) {
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
|
||||
if (folder.path in removed || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
out.add(canonical(folder))
|
||||
}
|
||||
}
|
||||
AddedExes.list(context).filter { it.folder !in removed }.forEach { out.add(canonical(File(it.folder))) }
|
||||
StoreInstallRoot.gameFolders(context).forEach { out.add(canonical(it)) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders under a library's steamapps/common that a manifest beside it already claims.
|
||||
* Steam lists those itself, so a shortcut would only add a non-Steam copy of the game. Steam
|
||||
@@ -123,30 +385,49 @@ object AddedGames {
|
||||
|
||||
private val INSTALL_DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
/** [single]: the entry a game added from the Games tab has; its exe stands in when no other is chosen and the user picked it. */
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
|
||||
run {
|
||||
val candidates = candidates(folder)
|
||||
// A store install carries a sidecar; every one of them is a shortcut.
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
val ranking = rank(folder)
|
||||
val candidates = ranking.exes
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val found = chosen ?: candidates.firstOrNull() ?: return
|
||||
val foundGuest = guestPath(context, found)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
|
||||
val name = folder.name
|
||||
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile }
|
||||
// A store install says which exe it is; the folder's guess only when that file is gone.
|
||||
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
// The store's launcher .bat stands in front of the exe when the game needs arguments or
|
||||
// environment; it is what the shortcut runs. Dropped when the user picked another exe.
|
||||
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val foundGuest = guestPath(context, launch)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||
// A store install's title; else the title the disk gives (the shortcut's appid is kept
|
||||
// per folder from its first scan, so a name found later does not move it); else the folder.
|
||||
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + autoName).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
// Name, Start in and launch options edited in Steam's own Properties become the app's.
|
||||
if (sidecar == null && record != null) adoptSteamEdits(context, folder, record)
|
||||
val name = SessionPrefs.addedGameName(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() } ?: autoName
|
||||
val adopted = guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val guestDir = SessionPrefs.addedGameStartIn(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() }
|
||||
?: guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
|
||||
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
|
||||
launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "",
|
||||
single = single != null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +460,35 @@ object AddedGames {
|
||||
|
||||
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
|
||||
|
||||
/**
|
||||
* What the user changed in Steam's Properties since the app last wrote the shortcut ([record]:
|
||||
* what the app asked for, and what Steam had): a new name, launch options, or a Start in other
|
||||
* than the exe's folder, taken over as the app's own. Each Steam value is taken once, so an app
|
||||
* edit made after it is not undone before the shortcut is written again.
|
||||
*/
|
||||
internal fun adoptSteamEdits(context: Context, folder: File, record: JSONObject) {
|
||||
val app = record.optJSONObject("app") ?: return
|
||||
val steam = record.optJSONObject("steam") ?: return
|
||||
fun adopt(field: String, value: String, apply: (String) -> Unit) {
|
||||
if (SessionPrefs.addedGameAdopted(context, folder.path, field) == value) return
|
||||
SessionPrefs.setAddedGameAdopted(context, folder.path, field, value)
|
||||
apply(value)
|
||||
}
|
||||
if (steam.has("AppName") && app.has("AppName")) {
|
||||
val s = steam.optString("AppName"); val a = app.optString("AppName")
|
||||
if (s.isNotEmpty() && s != a) adopt("AppName", s) { SessionPrefs.setAddedGameName(context, folder.path, it) }
|
||||
}
|
||||
if (steam.has("LaunchOptions") && app.has("LaunchOptions")) {
|
||||
val s = steam.optString("LaunchOptions"); val a = app.optString("LaunchOptions")
|
||||
if (s != a) adopt("LaunchOptions", s) { SessionPrefs.setAddedGameLaunch(context, folder.path, it) }
|
||||
}
|
||||
if (steam.has("StartDir") && app.has("StartDir")) {
|
||||
val s = unquote(steam.optString("StartDir")); val a = unquote(app.optString("StartDir"))
|
||||
val exeDir = unquote(steam.optString("Exe")).substringBeforeLast('/', "")
|
||||
if (s.isNotEmpty() && s != a) adopt("StartDir", s) { SessionPrefs.setAddedGameStartIn(context, folder.path, if (it == exeDir) "" else it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -189,6 +499,7 @@ object AddedGames {
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
if (g.launchOptions.isNotEmpty()) json.append(",\"launch\":").append(quote(g.launchOptions))
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* The slow part of an added-game change, off the caller's path and in order: the running client
|
||||
* told ([later]'s work), then the listing the shortcuts writer reads at the next session start and
|
||||
* the library cache, both from one scan ([republish]). An add, an edit or a removal shows at once;
|
||||
* this follows.
|
||||
*/
|
||||
object AddedGamesPublisher {
|
||||
private const val TAG = "AddedGamesPublisher"
|
||||
|
||||
/** Tests publish at once, to read the listing right after a change. */
|
||||
@VisibleForTesting
|
||||
@Volatile
|
||||
internal var now = false
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "added-games-publish") }
|
||||
|
||||
/** [live] (a DevTools call or nothing), then [republish], on the worker. */
|
||||
fun later(context: Context, live: () -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
val work = Runnable {
|
||||
try { live() } catch (e: Exception) { Log.w(TAG, "live: ${e.message}") }
|
||||
try { republish(app) } catch (e: Exception) { Log.w(TAG, "listing: ${e.message}") }
|
||||
}
|
||||
if (now) work.run() else worker.execute(work)
|
||||
}
|
||||
|
||||
/** The listing and the library cache from one scan of the added games. Blocking. */
|
||||
fun republish(context: Context) {
|
||||
val games = AddedGames.scan(context)
|
||||
AddedGames.writeListing(context, games)
|
||||
LibraryCache.save(context, Library.launchableGames(context, added = games))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
|
||||
/**
|
||||
* Turns on, per game, the Windows components its source's list names ([WinCompSources]). Only the
|
||||
* findings marked auto are turned on, only the ones that install here, and never one the user
|
||||
* switched off: the user's switches are kept apart ([WinComponents.Selection.user]) and win.
|
||||
*
|
||||
* All the work is done by one background thread (low priority, so are the runtime helpers it
|
||||
* starts), one job at a time from one queue: working out a game's list (kept, so a page opens on
|
||||
* it) and downloading a component (each once, whichever games want it). The queue waits while a
|
||||
* session runs; jobs nobody asked for (the start-up sweep, a game being launched) also wait on
|
||||
* battery saver, a warm device or a metered network. Nothing here runs on the caller's thread.
|
||||
*/
|
||||
object AutoComponents {
|
||||
private const val TAG = "AutoComponents"
|
||||
|
||||
/** The key a game's picks are kept under: the appid its prefix is named after. */
|
||||
fun appKey(game: Library.SteamGame): String? =
|
||||
if (game.library == Library.ADDED) Integer.toUnsignedString(game.appId) else game.appId.takeIf { it > 0 }?.toString()
|
||||
|
||||
/**
|
||||
* What turns on by itself for [findings], given [catalog] and what is [installed]: the catalog
|
||||
* name each auto finding installs as, with its reason. A component that needs a Windows
|
||||
* installer, or is not in the catalog, stays out (the editor lists it as a suggestion).
|
||||
*/
|
||||
fun autoPicks(findings: List<WinCompSources.Finding>, catalog: Map<String, WinComponents.Component>): Map<String, WinComponents.AutoReason> {
|
||||
val out = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
for (f in findings) {
|
||||
if (!f.auto || f.component == null) continue
|
||||
val id = WinComponents.installable(f.component, catalog)
|
||||
val c = catalog[id] ?: continue
|
||||
if (WinComponents.support(c, catalog) != WinComponents.Support.READY) continue
|
||||
if (id !in out) out[id] = WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- seams for tests ----
|
||||
@VisibleForTesting internal var catalogOf: (Context, Boolean) -> List<WinComponents.Component>? =
|
||||
{ c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
|
||||
@VisibleForTesting internal var installedOf: (Context) -> Set<String> = { WinComponents.installedIds(it).toSet() }
|
||||
@VisibleForTesting internal var installer: (Context, WinComponents.Component, Map<String, WinComponents.Component>, (WinComponents.Progress) -> Unit) -> String? =
|
||||
{ c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
|
||||
@VisibleForTesting internal var sessionRunning: () -> Boolean = { SessionState.running }
|
||||
/** Battery saver, a warm device or a metered network: no work nobody asked for. */
|
||||
@VisibleForTesting internal var unfavourable: (Context) -> Boolean = { c -> constrained(c) }
|
||||
/** A usable network: a download waits for one. */
|
||||
@VisibleForTesting internal var online: (Context) -> Boolean = { c -> isOnline(c) }
|
||||
/** Tests run the queue on the caller's thread ([drain]) instead of the worker. */
|
||||
@VisibleForTesting @Volatile internal var runNow = false
|
||||
|
||||
/** Bumped whenever a game's list or automatic components change, so an open page reads them again. */
|
||||
val changes = MutableStateFlow(0)
|
||||
|
||||
/** The components downloading for an automatic pick right now, with their progress. */
|
||||
val downloading = MutableStateFlow<Map<String, WinComponents.Progress>>(emptyMap())
|
||||
|
||||
/** Games (by [appKey]) whose list is queued or being worked out: a page waits on it. */
|
||||
val pendingGames = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Components queued for an automatic pick, not yet downloaded. */
|
||||
val pendingComponents = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
private fun mark(job: Job, on: Boolean) {
|
||||
if (job.pics != null) return
|
||||
val flow = if (job.component != null) pendingComponents else pendingGames
|
||||
val id = job.component ?: job.game?.let { appKey(it) } ?: return
|
||||
flow.value = if (on) flow.value + id else flow.value - id
|
||||
}
|
||||
|
||||
/** One piece of work: a game's list ([game]) or one component's download ([component]). [asked]: the user is waiting on it. */
|
||||
internal class Job(
|
||||
val game: Library.SteamGame?, val component: String?, val asked: Boolean, val seq: Long, val pics: List<Int>? = null,
|
||||
/** For a game Steam installed during the running session: may run in it, unless a game is running. */
|
||||
val inSession: Boolean = false,
|
||||
) {
|
||||
val key get() = when {
|
||||
pics != null -> "pics"
|
||||
component != null -> "c:$component"
|
||||
else -> "g:" + (game?.let { appKey(it) } ?: "")
|
||||
}
|
||||
/** Needs the network: a download, or Steam asked anonymously. */
|
||||
val online get() = component != null || pics != null
|
||||
}
|
||||
|
||||
private val seq = java.util.concurrent.atomic.AtomicLong()
|
||||
/** What the user asked for first, then in the order queued. */
|
||||
private val jobs = PriorityBlockingQueue<Job>(16, compareByDescending<Job> { it.asked }.thenBy { it.seq })
|
||||
@Volatile private var draining = false
|
||||
private val queuedKeys = java.util.Collections.synchronizedSet(HashSet<String>())
|
||||
/** The games waiting on a component's download, to turn it on for when it lands. */
|
||||
private val waiting = HashMap<String, MutableList<Library.SteamGame>>()
|
||||
@Volatile private var worker: Thread? = null
|
||||
private const val PAUSE_MS = 15_000L
|
||||
|
||||
/**
|
||||
* [game]'s list worked out again on the worker, and its components queued: [asked] when the
|
||||
* user is waiting (its page is open, or they installed or added it).
|
||||
*/
|
||||
fun queueGame(context: Context, game: Library.SteamGame, asked: Boolean) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, asked, seq.incrementAndGet()))
|
||||
}
|
||||
|
||||
private fun queueDownload(context: Context, id: String, asked: Boolean, inSession: Boolean = false) =
|
||||
enqueue(context, Job(null, id, asked, seq.incrementAndGet(), inSession = inSession))
|
||||
|
||||
/** Adds [job] unless the same one is queued (a game or a component, once each). */
|
||||
private fun enqueue(context: Context, job: Job) {
|
||||
if (!queuedKeys.add(job.key)) {
|
||||
// Asked for now: ahead of what nobody asked for, so the open page does not wait behind the sweep.
|
||||
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
|
||||
if (runNow && !draining) drain(context)
|
||||
return
|
||||
}
|
||||
if (job.component != null) Log.i(TAG, "wincomp: queued ${job.component}")
|
||||
mark(job, true)
|
||||
jobs.add(job)
|
||||
if (runNow) { if (!draining) drain(context) } else ensureWorker(context.applicationContext)
|
||||
}
|
||||
|
||||
/** Whether [job] has to wait: always while a session runs; one nobody asked for also when it is unfavourable. */
|
||||
internal fun mustWait(context: Context, job: Job): Boolean =
|
||||
if (job.inSession && sessionRunning()) gameRunning() || (job.online && !online(context))
|
||||
else sessionRunning() || (job.online && !online(context)) || (!job.asked && unfavourable(context))
|
||||
|
||||
/** A game is running in the session (Steam's reaper, or Proton waiting on its game). */
|
||||
@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
|
||||
|
||||
/**
|
||||
* A game Steam installed during the running session: its list worked out and its components
|
||||
* readied in the session (one job, low priority), so a launch right after finds them; it
|
||||
* waits while a game runs. All other work stays paused until the session ends.
|
||||
*/
|
||||
fun queueInSession(context: Context, game: Library.SteamGame) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, true, seq.incrementAndGet(), inSession = true))
|
||||
}
|
||||
|
||||
/** Runs the queued jobs on this thread until the queue is empty or the next one has to wait. Tests. */
|
||||
@VisibleForTesting
|
||||
internal fun drain(context: Context) {
|
||||
draining = true
|
||||
try {
|
||||
while (true) {
|
||||
val job = next(context) ?: return
|
||||
queuedKeys.remove(job.key)
|
||||
run(context, job)
|
||||
}
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The first job in order that may run now, taken off the queue; null when none may. */
|
||||
private fun next(context: Context): Job? = synchronized(jobs) {
|
||||
val job = jobs.sortedWith(order).firstOrNull { !mustWait(context, it) } ?: return null
|
||||
jobs.remove(job)
|
||||
job
|
||||
}
|
||||
|
||||
private val order = compareByDescending<Job> { it.asked }.thenBy { it.seq }
|
||||
|
||||
@VisibleForTesting internal fun queued(): List<String> = jobs.sortedWith(compareByDescending<Job> { it.asked }.thenBy { it.seq }).map { it.key }
|
||||
|
||||
@Synchronized
|
||||
private fun ensureWorker(app: Context) {
|
||||
if (worker?.isAlive == true) return
|
||||
worker = Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
while (true) {
|
||||
// Something queued, then the first that may run (one for a game installed in the
|
||||
// session goes ahead of paused work); else a look again in a while.
|
||||
try { jobs.put(jobs.take()) } catch (e: InterruptedException) { return@Thread }
|
||||
val job = next(app)
|
||||
if (job == null) {
|
||||
try { Thread.sleep(PAUSE_MS) } catch (e: InterruptedException) { return@Thread }
|
||||
continue
|
||||
}
|
||||
queuedKeys.remove(job.key)
|
||||
WinComponents.background { run(app, job) }
|
||||
}
|
||||
}, "auto-components").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun run(context: Context, job: Job) {
|
||||
try {
|
||||
when {
|
||||
job.pics != null -> SteamAppInfo.lookup(context, job.pics)
|
||||
job.component != null -> download(context, job.component, job.asked, job.inSession)
|
||||
else -> job.game?.let { apply(context, it, job.asked, job.inSession) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "wincomp: ${job.key}: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} finally {
|
||||
mark(job, false)
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [game]'s list (worked out again only when what it is read from changed), its automatic
|
||||
* components that are downloaded turned on, and the rest queued for download.
|
||||
*/
|
||||
private fun apply(context: Context, game: Library.SteamGame, asked: Boolean, inSession: Boolean = false) {
|
||||
val key = appKey(game) ?: return
|
||||
val folder = game.gameFiles
|
||||
if (game.library == Library.ADDED && game.source == Library.ADDED && folder != null && SteamMatch.get(context, folder.path) == null) {
|
||||
AddedGames.single(context, folder.path)?.let { SteamMatch.resolve(context, folder.path, it.exe, it.name) }
|
||||
}
|
||||
// A GOG or Epic game installed before its sidecar kept the store's list: read it once from the store.
|
||||
val store = com.droiddeck.launcher.stores.Store.byId(game.source)
|
||||
if (folder != null && (store == com.droiddeck.launcher.stores.Store.GOG || store == com.droiddeck.launcher.stores.Store.EPIC)) StoreListBackfill.fill(context, folder)
|
||||
// Its Steam app, when the client never saw it: Steam asked anonymously now if the user is
|
||||
// waiting on this game (a pass over every game asks for all of them in one go, [sweep]).
|
||||
if (asked && online(context)) steamAppOf(context, game)?.let { SteamAppInfo.lookup(context, listOf(it)) }
|
||||
val findings = findings(context, game)
|
||||
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 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 }
|
||||
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)
|
||||
}
|
||||
// In a session the launch waits a little for these (droiddeck-wincomponents reads the list).
|
||||
if (inSession) Pending.set(context, key, missing)
|
||||
val before = WinComponents.selection(context, key).auto
|
||||
if (before.keys != kept.keys) {
|
||||
WinComponents.setAuto(context, key, kept)
|
||||
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
|
||||
}
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
|
||||
/** One component downloaded and installed, then turned on for every game waiting on it. */
|
||||
private fun download(context: Context, id: String, asked: Boolean, inSession: Boolean = false) {
|
||||
val catalog = catalogOf(context, true)?.associateBy { it.name } ?: run { Pending.drop(context, id); return }
|
||||
if (id !in installedOf(context)) {
|
||||
val c = catalog[id] ?: run { Pending.drop(context, id); return }
|
||||
val started = System.currentTimeMillis()
|
||||
downloading.value = downloading.value + (id to WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1))
|
||||
val problem = try {
|
||||
installer(context, c, catalog) { p -> downloading.value = downloading.value + (id to p) }
|
||||
} finally {
|
||||
downloading.value = downloading.value - id
|
||||
}
|
||||
if (problem != null) { Log.w(TAG, "wincomp: $id did not install: $problem"); Pending.drop(context, id); return }
|
||||
Log.i(TAG, "wincomp: $id installed in ${System.currentTimeMillis() - started}ms")
|
||||
}
|
||||
val games = synchronized(waiting) { waiting.remove(id).orEmpty() }
|
||||
games.forEach { apply(context, it, asked, inSession) }
|
||||
Pending.drop(context, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The components still being readied for games installed in the session, where the launch
|
||||
* reads them (~/.config/droiddeck/wincomponents-pending.json in the runtime).
|
||||
*/
|
||||
internal object Pending {
|
||||
private fun file(context: Context) = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/wincomponents-pending.json")
|
||||
|
||||
@Synchronized fun read(context: Context): JSONObject =
|
||||
runCatching { JSONObject(file(context).readText()) }.getOrDefault(JSONObject()).let { it.optJSONObject("games") ?: JSONObject() }
|
||||
|
||||
@Synchronized fun set(context: Context, key: String, ids: List<String>) {
|
||||
val games = read(context)
|
||||
if (ids.isEmpty()) games.remove(key) else games.put(key, JSONArray(ids))
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
@Synchronized fun drop(context: Context, id: String) {
|
||||
val games = read(context)
|
||||
for (k in games.keys().asSequence().toList()) {
|
||||
val left = games.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - id
|
||||
if (left.isEmpty()) games.remove(k) else games.put(k, JSONArray(left))
|
||||
}
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
private fun write(context: Context, games: JSONObject) {
|
||||
val f = file(context)
|
||||
runCatching {
|
||||
f.parentFile?.mkdirs()
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(JSONObject().put("games", games).toString())
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the list, kept per game ----
|
||||
|
||||
private const val FINDINGS = "wincomp-findings.json"
|
||||
|
||||
/** What the list is read from, by modification time: a change means it is worked out again. */
|
||||
internal fun signature(context: Context, game: Library.SteamGame): String {
|
||||
val folder = game.gameFiles
|
||||
val parts = listOfNotNull(
|
||||
folder?.lastModified(),
|
||||
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
|
||||
folder?.let { StoreGameSidecar.file(it).lastModified() },
|
||||
folder?.let { File(it, "fuel.json").lastModified() },
|
||||
SteamAppInfo.appinfoFile(context).lastModified(),
|
||||
folder?.let { SteamMatch.get(context, it.path) }?.let { "${it.appId}/${it.certainty}" },
|
||||
steamAppOf(context, game)?.let { SteamAppInfo.state(context, it) },
|
||||
)
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
/** [game]'s list: the one kept when nothing it is read from changed, else worked out again and kept. Worker only. */
|
||||
internal fun findings(context: Context, game: Library.SteamGame): List<WinCompSources.Finding> {
|
||||
val key = appKey(game) ?: return emptyList()
|
||||
val sig = signature(context, game)
|
||||
val all = readFindings(context)
|
||||
all.optJSONObject(key)?.takeIf { it.optString("sig") == sig }?.let { return decode(it.optJSONArray("findings")) }
|
||||
val fresh = WinCompSources.forGame(context, game)
|
||||
writeFindings(context, all.put(key, JSONObject().put("sig", sig).put("findings", encode(fresh))))
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** The list kept for [appKey], without working anything out; null before the first time. */
|
||||
fun cachedFindings(context: Context, appKey: String): List<WinCompSources.Finding>? =
|
||||
readFindings(context).optJSONObject(appKey)?.let { decode(it.optJSONArray("findings")) }
|
||||
|
||||
@Synchronized
|
||||
private fun readFindings(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, FINDINGS)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
private fun writeFindings(context: Context, json: JSONObject) {
|
||||
val atomic = AtomicFile(File(context.filesDir, FINDINGS))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
private fun encode(list: List<WinCompSources.Finding>) = JSONArray().apply {
|
||||
list.forEach { f ->
|
||||
put(JSONObject().put("original", f.original).put("origin", f.origin.name).put("auto", f.auto).put("args", JSONArray(f.args))
|
||||
.apply { f.component?.let { put("component", it) } })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(arr: JSONArray?): List<WinCompSources.Finding> = if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val origin = runCatching { WinCompSources.Origin.valueOf(o.optString("origin")) }.getOrNull() ?: return@mapNotNull null
|
||||
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
|
||||
WinCompSources.Finding(o.optString("component").takeIf { it.isNotEmpty() }, o.optString("original"), origin, o.optBoolean("auto"), args)
|
||||
}
|
||||
|
||||
// ---- when ----
|
||||
|
||||
/** Before a launch from DroidDeck: nothing but a queued look (it waits for the session to end). */
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
|
||||
|
||||
/** After an install or an add: the user did it, so it does not wait on a metered network. */
|
||||
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
|
||||
|
||||
/**
|
||||
* Every game, whatever its source, at app start and after each session (Steam installs its
|
||||
* games without a word to DroidDeck; older GOG and Epic installs get their list read once):
|
||||
* only queued, one game at a time, behind anything asked for, under the same conditions. A
|
||||
* game whose files did not change costs a look at their times.
|
||||
*/
|
||||
fun sweep(context: Context, games: List<Library.SteamGame>) {
|
||||
// First every Steam app these games are that the client never saw, asked of Steam in one
|
||||
// connection; then each game's list.
|
||||
val apps = games.mapNotNull { steamAppOf(context, it) }.distinct()
|
||||
if (apps.isNotEmpty() && SteamAppInfo.needLookup(context, apps).isNotEmpty()) enqueue(context, Job(null, null, false, seq.incrementAndGet(), pics = apps))
|
||||
games.forEach { queueGame(context, it, asked = false) }
|
||||
}
|
||||
|
||||
/** The Steam app whose list a game takes: a Steam game's own, or an added game's match. */
|
||||
private fun steamAppOf(context: Context, game: Library.SteamGame): Int? =
|
||||
if (game.library != Library.ADDED) game.appId.takeIf { it > 0 }
|
||||
else game.gameFiles?.let { SteamMatch.get(context, it.path) }?.appId
|
||||
|
||||
/** The default network is there and validated (reaches the internet). */
|
||||
fun isOnline(context: Context): Boolean {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
|
||||
return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
/** Battery saver, thermal status moderate or worse, or a metered network. */
|
||||
private fun constrained(context: Context): Boolean {
|
||||
val power = context.getSystemService(PowerManager::class.java)
|
||||
if (power?.isPowerSaveMode == true) return true
|
||||
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
|
||||
return context.getSystemService(ConnectivityManager::class.java)?.isActiveNetworkMetered == true
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,9 @@ object DependencyDetector {
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) continue
|
||||
// Unity's <Game>_Data/Managed holds the game's own Mono assemblies, not a .NET
|
||||
// Framework to install.
|
||||
if (lower == "managed" && dir.name.endsWith("_Data", ignoreCase = true)) continue
|
||||
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
|
||||
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
|
||||
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
|
||||
@@ -157,6 +160,19 @@ object DependencyDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog components one installer stands for, by its path ("_CommonRedist/vcredist/2015/
|
||||
* vc_redist.x64.exe", "DXSETUP.exe"): what a store's install list names. A VC++ installer
|
||||
* with no year is the latest; empty for one that is none of ours.
|
||||
*/
|
||||
fun componentsFor(path: String): List<String> {
|
||||
val p = path.replace('\\', '/').lowercase(Locale.US)
|
||||
return classify(p, p.substringAfterLast('/'), isDir = false).map { if (it == VCREDIST_LOOSE) "vcredist2022" else it }
|
||||
}
|
||||
|
||||
/** The name the catalog component [name] goes by, for a reason line. */
|
||||
fun label(name: String): String = LABELS[name] ?: name
|
||||
|
||||
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
|
||||
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
|
||||
val loose = hits.remove(VCREDIST_LOOSE) ?: return
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which `.exe` in a game folder is the game. A real install holds installers, crash handlers,
|
||||
* anti-cheat stubs and launchers beside the game, and the game itself may sit several levels down
|
||||
* (`Game/Binaries/Win64/Game-Win64-Shipping.exe`), so candidates are collected with a bounded walk
|
||||
* and ranked rather than guessed by position.
|
||||
*/
|
||||
object GameExePicker {
|
||||
/** How far below a game folder to look: Unreal puts the game three down, repacks deeper. */
|
||||
const val MAX_DEPTH = 5
|
||||
|
||||
/** Folders and .exe files looked at per game before the walk stops, so a huge game stays quick. */
|
||||
const val WALK_BUDGET = 400
|
||||
|
||||
/** Below this the pick is marked uncertain rather than trusted silently. */
|
||||
const val CONFIDENT_SCORE = 60
|
||||
|
||||
/** The best and the runner-up this close together: nothing clearly won. */
|
||||
const val AMBIGUOUS_MARGIN = 15
|
||||
|
||||
/** Folders that never hold the game itself. */
|
||||
private val SKIP_DIRS = setOf(
|
||||
"_commonredist", "commonredist", "redist", "redistributable", "redistributables",
|
||||
"directx", "dotnet", "dotnetfx", "vcredist", "vc_redist", "prerequisites", "prereq",
|
||||
"installer", "installers", "__installer", "support", "extras", "docs", "documentation", "manual",
|
||||
"soundtrack", "ost", "artbook", "bonus", "dlc", "mods", "saves", "savegames",
|
||||
"crashreportclient", "easyanticheat", "battleye", "punkbuster", "steamworks shared", ".git",
|
||||
)
|
||||
private val SKIP_DIR_PREFIX = listOf("dotnet", "vcredist", "$")
|
||||
private val SKIP_PATHS = listOf("engine/extras", "engine/binaries/thirdparty")
|
||||
|
||||
/** Exe names that are never the game. */
|
||||
private val JUNK_EXE_RE = Regex(
|
||||
"""(?i)^(unins\w*|setup|install\w*|vc_?redist.*|vcredist.*|dxsetup|dxwebsetup|directx.*|""" +
|
||||
"""oalinst|openal.*|dotnetfx.*|ndp\d.*|unitycrashhandler\d*|crashreport\w*|crashpad\w*|""" +
|
||||
"""crashsender\w*|easyanticheat\w*|eac\w*|battleye\w*|beservice\w*|punkbuster\w*|""" +
|
||||
"""steamerrorreporter\d*|gameoverlayui|touchup|cleanup|config|settings|benchmark|""" +
|
||||
"""activation\w*|register\w*|readme|report\w*|.*_debug|.*-debug)$""",
|
||||
)
|
||||
|
||||
/** A folder's exes best first, with their scores, and whether the first clearly won. */
|
||||
class Ranking(val exes: List<File>, val scores: List<Int>, val uncertain: Boolean)
|
||||
|
||||
/** Ranks the .exe files under [folder]; [keep] drops names the caller never offers. */
|
||||
fun rank(folder: File, keep: (File) -> Boolean = { true }): Ranking {
|
||||
val scored = collect(folder).filter { keep(it.first) }.map { (exe, depth) -> exe to score(exe, folder, depth) }
|
||||
.sortedWith(compareByDescending<Pair<File, Int>> { it.second }.thenBy { it.first.path.lowercase() })
|
||||
val best = scored.firstOrNull()?.second
|
||||
val runnerUp = scored.getOrNull(1)?.second
|
||||
val uncertain = best != null && (best < CONFIDENT_SCORE || (runnerUp != null && best - runnerUp < AMBIGUOUS_MARGIN))
|
||||
return Ranking(scored.map { it.first }, scored.map { it.second }, uncertain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every plausible exe under [folder] with its depth (0 = in the folder itself): up to
|
||||
* [MAX_DEPTH] folders down, nearest levels first, at most [WALK_BUDGET] entries, never through a
|
||||
* symlink, past folders that never hold the game.
|
||||
*/
|
||||
private fun collect(folder: File): List<Pair<File, Int>> {
|
||||
val found = ArrayList<Pair<File, Int>>()
|
||||
var visited = 0
|
||||
var level = listOf(folder)
|
||||
var depth = 0
|
||||
walk@ while (level.isNotEmpty()) {
|
||||
val next = ArrayList<File>()
|
||||
for (dir in level) {
|
||||
val entries = dir.listFiles()?.sortedBy { it.name.lowercase() } ?: continue
|
||||
for (f in entries) {
|
||||
if (visited >= WALK_BUDGET) break@walk
|
||||
if (isLink(f)) continue
|
||||
if (f.isDirectory) {
|
||||
visited++
|
||||
if (depth < MAX_DEPTH && !skipDir(f, f.relativeTo(folder).invariantSeparatorsPath)) next.add(f)
|
||||
} else if (f.isFile && f.name.endsWith(".exe", ignoreCase = true)) {
|
||||
visited++
|
||||
if (!JUNK_EXE_RE.matches(f.nameWithoutExtension)) found.add(f to depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
level = next
|
||||
depth++
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* How likely [exe] is to be the game in [folder]. Relative only: the numbers rank candidates
|
||||
* against each other and decide whether anything won clearly enough to trust.
|
||||
*/
|
||||
internal fun score(exe: File, folder: File, depth: Int): Int {
|
||||
var score = 0
|
||||
val base = exe.nameWithoutExtension
|
||||
val exeKey = key(base)
|
||||
val folderKey = key(folder.name)
|
||||
// The exe named after its folder is the single strongest signal.
|
||||
if (exeKey.isNotEmpty() && folderKey.isNotEmpty()) {
|
||||
when {
|
||||
exeKey == folderKey -> score += 100
|
||||
folderKey.contains(exeKey) || exeKey.contains(folderKey) -> score += 60
|
||||
}
|
||||
}
|
||||
// Unreal ships as <Game>-Win64-Shipping.exe under Binaries/Win64.
|
||||
if (base.endsWith("-Win64-Shipping", true) || base.endsWith("-Win32-Shipping", true)) score += 70
|
||||
val path = exe.absolutePath.lowercase()
|
||||
if (path.contains("/binaries/win64") || path.contains("/binaries/win32")) score += 30
|
||||
if (path.contains("/bin/") || path.contains("/bin64/")) score += 10
|
||||
// A launcher is a real entry point, so it stays a candidate, just not the preferred one.
|
||||
if (GameIdentifier.isLauncherExeName(base)) score -= 40
|
||||
if (GameIdentifier.isJunkPeName(base)) score -= 30
|
||||
// Shallower is more likely the entry point; big binaries beat small helpers.
|
||||
score -= depth * 8
|
||||
score += (exe.length() / (8L * 1024 * 1024)).toInt().coerceAtMost(30)
|
||||
return score
|
||||
}
|
||||
|
||||
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
|
||||
val rel = relative.lowercase()
|
||||
return SKIP_PATHS.any { rel == it || rel.endsWith("/$it") }
|
||||
}
|
||||
|
||||
private fun isLink(f: File): Boolean = runCatching { java.nio.file.Files.isSymbolicLink(f.toPath()) }.getOrDefault(false)
|
||||
|
||||
/** Comparison key: letters and digits only, so "GTA IV" and "gta-iv" match. */
|
||||
private fun key(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ object GameFileSync {
|
||||
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
|
||||
}
|
||||
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
|
||||
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Best-effort identification of a game from its on-disk footprint, given its .exe: a Steam appId
|
||||
* and/or a display name, so an added game is not just named after its folder or exe. Pure
|
||||
* filesystem and PE parsing, no network, never throws; it reports only what the disk tells.
|
||||
*
|
||||
* Signal chain (appId preferred; best name chosen across all hits):
|
||||
* 1. steam_appid.txt → appId (Goldberg-style installs)
|
||||
* 2. ColdClientLoader / emu ini → appId (AppId= key)
|
||||
* 3. appmanifest_<id>.acf → appId + name (real Steam install under steamapps/)
|
||||
* 4. goggame-<id>.info → name (+ gogId) (GOG installs)
|
||||
* 5. PE VS_VERSIONINFO → name (ProductName / FileDescription)
|
||||
* 6. cleaned folder / exe name → name (weak fallback)
|
||||
*/
|
||||
object GameIdentifier {
|
||||
|
||||
/** How far up the directory tree to look for marker files (games nest deep, e.g. Game/Binaries/Win64/x.exe). */
|
||||
private const val MAX_PARENT_DEPTH = 6
|
||||
|
||||
enum class Source { STEAM_APPID_TXT, STEAM_MANIFEST_ACF, STEAM_EMU_INI, GOG_INFO, PE_VERSION, FILENAME, NONE }
|
||||
enum class Confidence { HIGH, MEDIUM, LOW }
|
||||
|
||||
/**
|
||||
* @param appId Steam appId if one was found on disk, else null.
|
||||
* @param gogId GOG product id if a goggame info file was found, else null.
|
||||
* @param name Best display name found locally; may be null when only an appId was
|
||||
* found (caller resolves the name from the appId over the network).
|
||||
* @param source Which signal produced the primary identity.
|
||||
*/
|
||||
data class GameIdentity(
|
||||
val appId: Int? = null,
|
||||
val gogId: String? = null,
|
||||
val name: String? = null,
|
||||
val source: Source = Source.NONE,
|
||||
val confidence: Confidence = Confidence.LOW,
|
||||
/** Which signal gave [name]: [Source.FILENAME] means only a cleaned folder or exe name. */
|
||||
val nameSource: Source = Source.NONE,
|
||||
) {
|
||||
val hasStrongId: Boolean get() = appId != null
|
||||
}
|
||||
|
||||
/** Identify the game owning [exeFile]. Never throws; returns an empty identity if nothing matched. */
|
||||
fun identify(exeFile: File): GameIdentity {
|
||||
val dirs = ancestorDirs(exeFile)
|
||||
|
||||
// Steam appId from a bare steam_appid.txt (in the exe dir, a steam_settings/ subdir, or an ancestor).
|
||||
val steamAppId = dirs.firstNotNullOfOrNull { readSteamAppIdTxt(it) }
|
||||
// AppId= out of a ColdClientLoader.ini / steam_emu.ini next to the game.
|
||||
val emuAppId = dirs.firstNotNullOfOrNull { readEmuIniAppId(it) }
|
||||
// Real Steam install: appmanifest_<id>.acf up under steamapps/ — carries both appId and name.
|
||||
val acf = resolveSteamManifest(exeFile, dirs)
|
||||
// GOG: goggame-<id>.info JSON with the real title.
|
||||
val gog = dirs.firstNotNullOfOrNull { readGogInfo(it) }
|
||||
// PE ProductName / FileDescription — but not when it merely names a launcher/loader
|
||||
// ("Rockstar Games Launcher Redirector", "GSE", …), which must not become the game name.
|
||||
val peName = PeVersionInfo.bestName(PeVersionInfo.read(exeFile))?.takeUnless { isJunkPeName(it) }
|
||||
|
||||
val appId = acf?.appId ?: steamAppId ?: emuAppId
|
||||
|
||||
// Best name, most-trustworthy first.
|
||||
val nameHit: Pair<String, Source>? = when {
|
||||
acf?.name != null -> acf.name to Source.STEAM_MANIFEST_ACF
|
||||
gog?.name != null -> gog.name to Source.GOG_INFO
|
||||
peName != null -> peName to Source.PE_VERSION
|
||||
else -> cleanedFallbackName(exeFile)?.let { it to Source.FILENAME }
|
||||
}
|
||||
|
||||
// Primary source = whatever gave us the appId (strongest), else the name source.
|
||||
val primary: Source = when {
|
||||
acf?.appId != null -> Source.STEAM_MANIFEST_ACF
|
||||
steamAppId != null -> Source.STEAM_APPID_TXT
|
||||
emuAppId != null -> Source.STEAM_EMU_INI
|
||||
else -> nameHit?.second ?: Source.NONE
|
||||
}
|
||||
|
||||
val confidence = when {
|
||||
appId != null -> Confidence.HIGH
|
||||
nameHit?.second == Source.GOG_INFO -> Confidence.HIGH
|
||||
nameHit?.second == Source.PE_VERSION -> Confidence.MEDIUM
|
||||
else -> Confidence.LOW
|
||||
}
|
||||
|
||||
return GameIdentity(
|
||||
appId = appId,
|
||||
gogId = gog?.id,
|
||||
name = nameHit?.first?.let(::normalizeName)?.takeIf { it.isNotBlank() },
|
||||
source = primary,
|
||||
confidence = confidence,
|
||||
nameSource = nameHit?.second ?: Source.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an identified title readable and filesystem-safe (a Steam shortcut's name also names
|
||||
* files): "DARK SOULS: REMASTERED" becomes "DARK SOULS - REMASTERED". Strips trademark marks,
|
||||
* turns title punctuation into safe equivalents, and collapses whitespace.
|
||||
*/
|
||||
fun normalizeName(raw: String): String {
|
||||
var s = raw
|
||||
s = s.replace("™", "").replace("®", "").replace("©", "")
|
||||
s = s.replace(Regex("""(?i)\(\s*(tm|r|c)\s*\)"""), "") // "(TM)" / "(R)" / "(C)"
|
||||
s = s.replace(":", " - ") // colon → dash separator (safe + readable)
|
||||
s = s.replace(Regex("""[\\/]"""), " ") // path separators → space
|
||||
s = s.replace(Regex("""["*?<>|]"""), "") // other illegal chars → drop
|
||||
// Drop trailing generic descriptors a PE FileDescription tacks on ("DiRT 3 Executable" → "DiRT 3").
|
||||
s = s.replace(Regex("""(?i)[ \-]+(executable|application|launcher|redistributable|installer|setup)$"""), "")
|
||||
s = s.replace(Regex("""\s+"""), " ").trim() // collapse whitespace
|
||||
return s.trim(' ', '-').trim() // tidy stray leading/trailing dashes
|
||||
}
|
||||
|
||||
// ── directory walk ──
|
||||
|
||||
/** The exe's directory plus its ancestors, nearest-first, bounded by [MAX_PARENT_DEPTH]. */
|
||||
private fun ancestorDirs(exeFile: File): List<File> {
|
||||
val out = ArrayList<File>(MAX_PARENT_DEPTH + 1)
|
||||
var d: File? = exeFile.absoluteFile.parentFile
|
||||
var depth = 0
|
||||
while (d != null && depth <= MAX_PARENT_DEPTH) {
|
||||
out.add(d)
|
||||
d = d.parentFile
|
||||
depth++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── signal readers (all best-effort; return null on anything unexpected) ──
|
||||
|
||||
/** steam_appid.txt directly in [dir] or in a steam_settings/ subdir. */
|
||||
private fun readSteamAppIdTxt(dir: File): Int? {
|
||||
for (candidate in arrayOf(File(dir, "steam_appid.txt"), File(dir, "steam_settings/steam_appid.txt"))) {
|
||||
// Strip a leading UTF-8 BOM (trim() doesn't) before reading the digits.
|
||||
val id = readSmallTextFile(candidate)?.removePrefix("\uFEFF")?.trim()
|
||||
?.takeWhile { it.isDigit() }?.toIntOrNull()
|
||||
if (id != null && id > 0) return id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* First `AppId=<n>` in a crack/emu config .ini next to the game (ColdClientLoader, steam_emu,
|
||||
* flt, hlm, …). Known names are tried first, then any other `.ini` in the dir — only crack
|
||||
* configs carry an `AppId=` key, game settings .ini files (settings.ini, graphics.ini) don't,
|
||||
* so the broad scan is safe.
|
||||
*/
|
||||
private fun readEmuIniAppId(dir: File): Int? {
|
||||
val re = Regex("""(?im)^\s*appid\s*=\s*(\d+)""")
|
||||
val named = arrayOf(
|
||||
"ColdClientLoader.ini", "steam_emu.ini", "flt.ini", "hlm.ini",
|
||||
"valve.ini", "cream_api.ini", "SmartSteamEmu.ini", "GreenLuma.ini",
|
||||
)
|
||||
val seen = HashSet<String>()
|
||||
val candidates = ArrayList<File>()
|
||||
for (n in named) File(dir, n).let { if (it.isFile) { candidates.add(it); seen.add(n.lowercase()) } }
|
||||
(dir.listFiles { f -> f.isFile && f.name.endsWith(".ini", ignoreCase = true) && f.name.lowercase() !in seen }
|
||||
?: emptyArray()).sortedBy { it.name }.forEach { candidates.add(it) }
|
||||
for (f in candidates) {
|
||||
val text = readSmallTextFile(f) ?: continue
|
||||
val id = re.find(text)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
if (id != null && id > 0) return id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private data class Acf(val appId: Int?, val name: String?)
|
||||
|
||||
/**
|
||||
* Resolve the Steam appmanifest for this game: find the `common` ancestor on the exe's
|
||||
* path, read the sibling `steamapps/appmanifest_*.acf`, and match by installdir == the
|
||||
* game folder under common. No match, no manifest: a folder in steamapps/common that no
|
||||
* manifest claims is exactly the kind added games list, and the library's one other game is
|
||||
* not its name.
|
||||
*/
|
||||
private fun resolveSteamManifest(exeFile: File, dirs: List<File>): Acf? {
|
||||
val commonIdx = dirs.indexOfFirst { it.name.equals("common", ignoreCase = true) }
|
||||
if (commonIdx < 0) return null
|
||||
val commonDir = dirs[commonIdx]
|
||||
val steamapps = commonDir.parentFile ?: return null
|
||||
val manifests = steamapps.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf")
|
||||
} ?: return null
|
||||
if (manifests.isEmpty()) return null
|
||||
|
||||
// The game's install folder is the child of `common` on our descent path.
|
||||
val gameFolder = if (commonIdx > 0) dirs[commonIdx - 1].name else null
|
||||
|
||||
val match = manifests.firstOrNull { m ->
|
||||
val text = readSmallTextFile(m) ?: return@firstOrNull false
|
||||
val installdir = vdfValue(text, "installdir")
|
||||
gameFolder != null && installdir != null && installdir.equals(gameFolder, ignoreCase = true)
|
||||
} ?: return null
|
||||
|
||||
val text = readSmallTextFile(match) ?: return null
|
||||
val appId = vdfValue(text, "appid")?.toIntOrNull()?.takeIf { it > 0 }
|
||||
val name = vdfValue(text, "name")?.takeIf { it.isNotBlank() }
|
||||
return if (appId == null && name == null) null else Acf(appId, name)
|
||||
}
|
||||
|
||||
private data class Gog(val id: String?, val name: String?)
|
||||
|
||||
/** goggame-<id>.info JSON: pull `name` (and the id from the filename). */
|
||||
private fun readGogInfo(dir: File): Gog? {
|
||||
val files = dir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("goggame-") && f.name.endsWith(".info")
|
||||
} ?: return null
|
||||
for (f in files) {
|
||||
val text = readSmallTextFile(f) ?: continue
|
||||
val name = jsonStringValue(text, "name")?.takeIf { it.isNotBlank() } ?: continue
|
||||
val id = f.name.removePrefix("goggame-").removeSuffix(".info").takeIf { it.isNotBlank() }
|
||||
return Gog(id, name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── filename fallback ──
|
||||
|
||||
private val REPACK_TOKENS = setOf(
|
||||
"fitgirl", "dodi", "codex", "plaza", "skidrow", "rune", "empress", "flt", "tenoke",
|
||||
"reloaded", "elamigos", "razor1911", "hoodlum", "gog", "repack", "multi", "goldberg",
|
||||
// repack/site tags that show up in folder names
|
||||
"ankergames", "steamrip", "onlinefix", "online-fix", "gload", "fitgirlrepacks",
|
||||
)
|
||||
private const val EXE_NOISE_WORDS =
|
||||
"""win64|win32|shipping|wingdk|client|launcher|game|x64|x86|64bit|32bit"""
|
||||
|
||||
/**
|
||||
* Does this exe basename look like engine/arch boilerplate rather than a title? Deliberately
|
||||
* UNANCHORED — "GameConTest" is boilerplate-ish wherever the giveaway sits, and the answer
|
||||
* decides whether we trust the folder name instead of the exe name.
|
||||
*/
|
||||
private val EXE_NOISE_RE = Regex("""(?i)[-_. ]*(?:$EXE_NOISE_WORDS)""")
|
||||
|
||||
/**
|
||||
* Trailing noise to strip off a title. ANCHORED to the end and allowed to repeat, because these
|
||||
* words are only junk as a suffix: "Witcher3-Win64-Shipping" -> "Witcher3", while
|
||||
* "Game of Thrones" and "Client Simulator" must survive intact.
|
||||
*
|
||||
* Kept separate from [EXE_NOISE_RE] on purpose. One regex used to serve both jobs, so
|
||||
* anchoring it to fix stripping ALSO narrowed the boilerplate test — and a game in
|
||||
* "Game Controller Test/GameConTest.exe" stopped preferring its folder and became "GameConTest".
|
||||
*/
|
||||
private val EXE_SUFFIX_RE = Regex("""(?i)(?:[-_. ]*(?:$EXE_NOISE_WORDS))+$""")
|
||||
private val VERSION_TOKEN_RE = Regex("""(?i)\b(v?\d+([._]\d+)+|build[-_ ]?\d+|update[-_ ]?\d+)\b""")
|
||||
|
||||
/**
|
||||
* True when an exe basename is a launcher/loader rather than the game — its parent folder is the
|
||||
* better title. Catches "PlayGTAV"/"Play - GTA IV"/"LaunchGame"/"MyLauncher"/"GSE" but NOT a real
|
||||
* title that merely starts with those letters ("PlayerUnknown"). A "play|launch|start|run" prefix
|
||||
* counts only when it's a whole token — followed by a separator or a camelCase uppercase boundary,
|
||||
* not another lowercase letter (checked case-sensitively, so it survives the case-insensitive JUNK
|
||||
* matching used elsewhere).
|
||||
*/
|
||||
/** True when an exe basename looks like a launcher/loader rather than the game itself. */
|
||||
fun isLauncherExeName(base: String): Boolean {
|
||||
val lower = base.lowercase()
|
||||
if (lower == "gse" || lower.endsWith("launcher") || lower.endsWith("redirector")) return true
|
||||
for (p in arrayOf("play", "launch", "start", "run")) {
|
||||
if (!lower.startsWith(p)) continue
|
||||
val rest = base.substring(p.length)
|
||||
if (rest.isEmpty()) return true // exactly "Play"
|
||||
val c = rest[0]
|
||||
if (!c.isLetterOrDigit() || c.isUpperCase()) return true // "Play - GTA IV" / camelCase "PlayGTAV"
|
||||
// lowercase continuation ("Player…") → part of a longer word, not a launcher
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** A PE ProductName/FileDescription that describes a launcher/loader, not the game — reject it so it
|
||||
* doesn't become the shortcut name (e.g. "Rockstar Games Launcher Redirector", "GSE", "Steam"). */
|
||||
private val JUNK_NAME_RE = Regex(
|
||||
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
|
||||
)
|
||||
|
||||
/** True when a PE name is a launcher/loader label rather than a real game title. */
|
||||
fun isJunkPeName(name: String): Boolean {
|
||||
val n = name.trim()
|
||||
return n.isEmpty() || JUNK_NAME_RE.matches(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the game's install-folder name (usually the real title) over the exe basename when the
|
||||
* exe is a UE-style shipping binary OR a launcher/loader; then strip repack/suffix noise.
|
||||
*/
|
||||
private fun cleanedFallbackName(exeFile: File): String? {
|
||||
val exeBase = exeFile.nameWithoutExtension
|
||||
val preferFolder = EXE_NOISE_RE.containsMatchIn(exeBase) ||
|
||||
isLauncherExeName(exeBase) ||
|
||||
exeBase.equals("game", true) || exeBase.equals("start", true)
|
||||
val folder = exeFile.absoluteFile.parentFile?.name
|
||||
val raw = if (preferFolder && !folder.isNullOrBlank()) folder else exeBase
|
||||
return cleanName(raw)
|
||||
}
|
||||
|
||||
/** Strip bracketed tags, repack-group names, engine/arch suffixes and version tokens; tidy separators. */
|
||||
private fun cleanName(raw: String): String? {
|
||||
var s = raw
|
||||
s = s.replace(Regex("""[\[(][^\])]*[\])]"""), " ") // (…) […]
|
||||
s = VERSION_TOKEN_RE.replace(s, " ")
|
||||
s = EXE_SUFFIX_RE.replace(s, " ")
|
||||
s = s.replace(Regex("""[._]+"""), " ")
|
||||
// Drop standalone repack-group words.
|
||||
s = s.split(Regex("""[\s-]+"""))
|
||||
.filter { it.isNotBlank() && it.lowercase() !in REPACK_TOKENS }
|
||||
.joinToString(" ")
|
||||
s = s.trim()
|
||||
return s.ifBlank { null }
|
||||
}
|
||||
|
||||
// ── tiny text/format helpers (regex-based → no Android/org.json dependency, unit-testable) ──
|
||||
|
||||
/** Read a small text file (guards against accidentally slurping a huge file). */
|
||||
private fun readSmallTextFile(f: File, maxBytes: Long = 1L * 1024 * 1024): String? = try {
|
||||
if (f.isFile && f.length() in 1..maxBytes) f.readText(Charsets.UTF_8) else null
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Value of a Valve KeyValues (.acf/.vdf) `"key" "value"` pair, first match, case-insensitive key. */
|
||||
private fun vdfValue(text: String, key: String): String? =
|
||||
Regex(""""${Regex.escape(key)}"\s*"([^"]*)"""", RegexOption.IGNORE_CASE).find(text)?.groupValues?.getOrNull(1)?.trim()
|
||||
|
||||
/** Value of a JSON `"key": "value"` pair, first match. Sufficient for the flat goggame .info files. */
|
||||
private fun jsonStringValue(text: String, key: String): String? =
|
||||
Regex(""""${Regex.escape(key)}"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"""").find(text)
|
||||
?.groupValues?.getOrNull(1)?.replace("\\\"", "\"")?.replace("\\\\", "\\")?.trim()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* Games folders (the roots earlier builds let the user add in Steam settings) become what the
|
||||
* Games tab's "Add all games in this folder" makes: one entry per game ([AddedExes]). Everything
|
||||
* a game had is keyed by its own folder and stays as it was - the chosen exe, the shortcut appid,
|
||||
* the name, Start in, launch options, art choices, a removal. The roots stay bound into the
|
||||
* session at the same guest paths ([SessionPrefs.gameFolderBinds]), so every exe keeps its path
|
||||
* inside the session and every Steam shortcut its target; they are no longer Steam libraries.
|
||||
* Runs whenever saved roots remain (so twice is the same as once) and clears them after.
|
||||
*/
|
||||
object GamesFolderMigration {
|
||||
private const val TAG = "GamesFolderMigration"
|
||||
|
||||
@Synchronized
|
||||
fun run(context: Context) {
|
||||
val dirs = SessionPrefs.addedGamesDirs(context)
|
||||
if (dirs.isEmpty()) return
|
||||
val roots = legacyRoots(SessionPrefs.gameFolderBinds(context).map { it.first } + dirs)
|
||||
.filter { root -> root.host.path in dirs }
|
||||
// The binds first: a game's guest path (and with it the shortcut's appid) depends on them.
|
||||
SessionPrefs.setGameFolderBinds(context, SessionPrefs.gameFolderBinds(context) + roots.map { it.host.path to it.guest })
|
||||
val removed = SessionPrefs.removedAddedGames(context).toSet()
|
||||
val entries = AddedExes.list(context).map { it.folder }.toMutableSet()
|
||||
val storeRoots = StoreInstallRoot.roots(context).map { it.absolutePath }
|
||||
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 (folder.name.equals("steamapps", ignoreCase = true) || 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++
|
||||
}
|
||||
Log.i(TAG, "games: migrated ${root.host} → $n games")
|
||||
}
|
||||
SessionPrefs.setAddedGamesDirs(context, emptyList())
|
||||
SessionPrefs.setGamesFoldersMigrated(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guest paths earlier builds gave Games folders: /root/Games/<folder name>, with a short
|
||||
* hash of the host path when two share a name. Kept exactly, so nothing moves in the session.
|
||||
*/
|
||||
internal fun legacyRoots(paths: List<String>): List<AddedGames.Root> {
|
||||
val hosts = paths.distinct().map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
AddedGames.Root(host, "${AddedGames.GUEST_DIR}/$guestName")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,18 @@ object Library {
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
val icon: File? = null,
|
||||
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
|
||||
val source: String = SOURCE_STEAM,
|
||||
/** The store's own id for a store install; null otherwise. */
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
}
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
|
||||
const val ADDED = "added"
|
||||
/** The [SteamGame.source] of a title Steam itself installed. */
|
||||
const val SOURCE_STEAM = "steam"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
@@ -146,20 +152,24 @@ object Library {
|
||||
}
|
||||
|
||||
/** The same installed-game inventory used for links, shortcuts and file exports. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
|
||||
/** [added]: the added games when the caller has just scanned them, so they are not scanned again. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
/** One added game as the Games tab lists it; [roots] the Steam libraries, for its Proton prefix. */
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
|
||||
@@ -26,6 +26,8 @@ object LibraryCache {
|
||||
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
|
||||
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
|
||||
icon = o.file("icon"),
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
|
||||
@@ -36,7 +38,8 @@ object LibraryCache {
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Reads the StringFileInfo strings (ProductName, FileDescription, …) out of the
|
||||
* VS_VERSION_INFO resource embedded in a Windows PE executable (.exe).
|
||||
*
|
||||
* Walks the PE resource directory to the first RT_VERSION resource — the same
|
||||
* navigation [com.droiddeck.launcher.files.PeIconExtractor] uses for icons — then parses the alignment-heavy
|
||||
* VS_VERSIONINFO → StringFileInfo → StringTable → String tree by hand.
|
||||
*
|
||||
* Everything is best-effort: any malformed / oversized / unsupported input returns
|
||||
* null (or an empty map). No allocation beyond the single resource blob.
|
||||
*/
|
||||
object PeVersionInfo {
|
||||
|
||||
private const val RT_VERSION = 16
|
||||
private const val MAX_RES_SECTION = 64 * 1024 * 1024 // don't slurp absurd resource sections
|
||||
|
||||
/**
|
||||
* Returns the StringFileInfo key→value map (e.g. `"ProductName" -> "Palworld"`),
|
||||
* or null if the file has no readable version resource. Keys are the raw
|
||||
* VS_VERSIONINFO names; look up [productName]/[fileDescription] for the common ones.
|
||||
*/
|
||||
fun read(file: File): Map<String, String>? = try {
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val blob = versionResourceBytes(raf) ?: return@use null
|
||||
parseStringFileInfo(blob).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Best display name from a version map. ProductName is usually the cleanest title, but is
|
||||
* sometimes an internal abbreviation (e.g. God of War ships ProductName "GoW" while
|
||||
* FileDescription is "God of War"). When ProductName is a single spaceless token and
|
||||
* FileDescription is a normal multi-word title, prefer FileDescription. Trimmed; null if
|
||||
* neither is meaningful.
|
||||
*/
|
||||
fun bestName(map: Map<String, String>?): String? {
|
||||
if (map == null) return null
|
||||
val product = map["ProductName"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val desc = map["FileDescription"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (product != null && desc != null &&
|
||||
!product.contains(' ') && desc.contains(' ') && desc.length <= 60
|
||||
) {
|
||||
return desc
|
||||
}
|
||||
return product ?: desc
|
||||
}
|
||||
|
||||
// ── locate the RT_VERSION resource blob (mirrors PeIconExtractor's navigation) ──
|
||||
|
||||
private fun versionResourceBytes(raf: RandomAccessFile): ByteArray? {
|
||||
val dos = read(raf, 0, 0x40) ?: return null
|
||||
if (u8(dos, 0) != 0x4D || u8(dos, 1) != 0x5A) return null // "MZ"
|
||||
val peOff = u32(dos, 0x3C).toInt()
|
||||
|
||||
val coff = read(raf, peOff.toLong(), 24) ?: return null
|
||||
if (u8(coff, 0) != 0x50 || u8(coff, 1) != 0x45 || u8(coff, 2) != 0 || u8(coff, 3) != 0) return null // "PE\0\0"
|
||||
val numSections = u16(coff, 6)
|
||||
val sizeOpt = u16(coff, 20)
|
||||
if (numSections <= 0 || numSections > 96 || sizeOpt <= 0) return null
|
||||
|
||||
val optOff = peOff + 24L
|
||||
val opt = read(raf, optOff, sizeOpt) ?: return null
|
||||
val magic = u16(opt, 0)
|
||||
val ddOff = if (magic == 0x20B) 112 else 96 // PE32+ vs PE32 data-directory offset
|
||||
val resDirEntry = ddOff + 2 * 8 // data directory index 2 = resource table
|
||||
if (resDirEntry + 8 > opt.size) return null
|
||||
val resRVA = u32(opt, resDirEntry).toInt()
|
||||
val resSize = u32(opt, resDirEntry + 4).toInt()
|
||||
if (resRVA == 0 || resSize == 0) return null
|
||||
|
||||
val secTabOff = optOff + sizeOpt
|
||||
val secTab = read(raf, secTabOff, numSections * 40) ?: return null
|
||||
|
||||
var secVA = 0
|
||||
var secPtr = 0L
|
||||
var secRaw = 0
|
||||
var found = false
|
||||
for (i in 0 until numSections) {
|
||||
val o = i * 40
|
||||
if (o + 40 > secTab.size) break
|
||||
val va = u32(secTab, o + 12).toInt()
|
||||
val vSize = u32(secTab, o + 8).toInt()
|
||||
val raw = u32(secTab, o + 16).toInt()
|
||||
val pRaw = u32(secTab, o + 20)
|
||||
val span = maxOf(vSize, raw)
|
||||
if (resRVA >= va && resRVA < va + span) {
|
||||
secVA = va; secPtr = pRaw; secRaw = raw; found = true; break
|
||||
}
|
||||
}
|
||||
if (!found || secRaw <= 0 || secRaw > MAX_RES_SECTION) return null
|
||||
|
||||
val res = read(raf, secPtr, secRaw) ?: return null
|
||||
val resDirStart = resRVA - secVA
|
||||
if (resDirStart < 0 || resDirStart + 16 > res.size) return null
|
||||
|
||||
val verTypeDir = findIdSubdir(res, resDirStart, RT_VERSION, resDirStart) ?: return null
|
||||
val verData = firstDataEntry(res, verTypeDir, resDirStart) ?: return null
|
||||
return dataEntryBytes(res, verData, secVA)
|
||||
}
|
||||
|
||||
/** Resolve the sub-directory under [dirIdx] whose id matches [wantId]; null if absent or not a directory. */
|
||||
private fun findIdSubdir(res: ByteArray, dirIdx: Int, wantId: Int, resDirStart: Int): Int? {
|
||||
if (dirIdx + 16 > res.size) return null
|
||||
val total = u16(res, dirIdx + 12) + u16(res, dirIdx + 14)
|
||||
val start = dirIdx + 16
|
||||
for (i in 0 until total) {
|
||||
val e = start + i * 8
|
||||
if (e + 8 > res.size) break
|
||||
val id = u32(res, e)
|
||||
val off = u32(res, e + 4)
|
||||
if (id and 0x80000000L == 0L && id.toInt() == wantId) {
|
||||
if (off and 0x80000000L != 0L) return resDirStart + (off and 0x7FFFFFFFL).toInt()
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Descend the first entry of each level until an IMAGE_RESOURCE_DATA_ENTRY; returns its index. */
|
||||
private fun firstDataEntry(res: ByteArray, dirIdx: Int, resDirStart: Int): Int? {
|
||||
if (dirIdx + 16 > res.size) return null
|
||||
if (u16(res, dirIdx + 12) + u16(res, dirIdx + 14) == 0) return null
|
||||
val e = dirIdx + 16
|
||||
if (e + 8 > res.size) return null
|
||||
val off = u32(res, e + 4)
|
||||
return if (off and 0x80000000L != 0L) {
|
||||
firstDataEntry(res, resDirStart + (off and 0x7FFFFFFFL).toInt(), resDirStart)
|
||||
} else {
|
||||
resDirStart + off.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the bytes an IMAGE_RESOURCE_DATA_ENTRY points at (its OffsetToData is an RVA). */
|
||||
private fun dataEntryBytes(res: ByteArray, entryIdx: Int, secVA: Int): ByteArray? {
|
||||
if (entryIdx < 0 || entryIdx + 8 > res.size) return null
|
||||
val rva = u32(res, entryIdx).toInt()
|
||||
val size = u32(res, entryIdx + 4).toInt()
|
||||
val start = rva - secVA
|
||||
if (size <= 0 || start < 0 || start + size > res.size) return null
|
||||
return res.copyOfRange(start, start + size)
|
||||
}
|
||||
|
||||
// ── VS_VERSIONINFO tree parse ──
|
||||
//
|
||||
// Every node is: WORD wLength, WORD wValueLength, WORD wType, WCHAR szKey[],
|
||||
// then (32-bit aligned) an optional Value, then (32-bit aligned) child nodes.
|
||||
// We only care about: VS_VERSIONINFO → StringFileInfo → StringTable → String.
|
||||
|
||||
private fun parseStringFileInfo(b: ByteArray): Map<String, String> {
|
||||
val out = LinkedHashMap<String, String>()
|
||||
if (b.size < 6) return out
|
||||
val rootLen = minOf(u16(b, 0), b.size)
|
||||
// VS_VERSION_INFO header: skip szKey "VS_VERSION_INFO" then the FIXEDFILEINFO value.
|
||||
val rootValLen = u16(b, 2)
|
||||
var pos = align4(6 + (utf16zLen(b, 6) + 1) * 2)
|
||||
pos = align4(pos + rootValLen)
|
||||
|
||||
while (pos + 6 <= rootLen) {
|
||||
val childLen = u16(b, pos)
|
||||
if (childLen < 6 || pos + childLen > b.size) break
|
||||
val key = utf16z(b, pos + 6)
|
||||
if (key == "StringFileInfo") parseTables(b, pos, pos + childLen, out)
|
||||
pos = align4(pos + childLen)
|
||||
if (childLen == 0) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Iterate the StringTable children inside a StringFileInfo block [start, end). */
|
||||
private fun parseTables(b: ByteArray, start: Int, end: Int, out: MutableMap<String, String>) {
|
||||
var p = align4(start + 6 + (utf16zLen(b, start + 6) + 1) * 2)
|
||||
while (p + 6 <= end) {
|
||||
val tableLen = u16(b, p)
|
||||
if (tableLen < 6 || p + tableLen > end) break
|
||||
parseStrings(b, p, minOf(p + tableLen, end), out)
|
||||
p = align4(p + tableLen)
|
||||
if (tableLen == 0) break
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterate the String children inside a StringTable block [start, end). */
|
||||
private fun parseStrings(b: ByteArray, start: Int, end: Int, out: MutableMap<String, String>) {
|
||||
var q = align4(start + 6 + (utf16zLen(b, start + 6) + 1) * 2)
|
||||
while (q + 6 <= end) {
|
||||
val strLen = u16(b, q)
|
||||
if (strLen < 6 || q + strLen > end) break
|
||||
val key = utf16z(b, q + 6)
|
||||
val valOff = align4(q + 6 + (key.length + 1) * 2)
|
||||
if (key.isNotEmpty() && valOff < q + strLen) {
|
||||
val value = utf16z(b, valOff, q + strLen).trim()
|
||||
if (value.isNotEmpty()) out[key] = value
|
||||
}
|
||||
q = align4(q + strLen)
|
||||
if (strLen == 0) break
|
||||
}
|
||||
}
|
||||
|
||||
// ── little-endian / UTF-16 readers ──
|
||||
|
||||
private fun align4(x: Int): Int = (x + 3) and 3.inv()
|
||||
|
||||
/** Number of UTF-16 code units before the null terminator starting at [off]. */
|
||||
private fun utf16zLen(b: ByteArray, off: Int): Int {
|
||||
var i = off
|
||||
while (i + 1 < b.size) {
|
||||
if (b[i].toInt() == 0 && b[i + 1].toInt() == 0) break
|
||||
i += 2
|
||||
}
|
||||
return (i - off) / 2
|
||||
}
|
||||
|
||||
/** Read a null-terminated UTF-16LE string starting at [off], not reading past [limit]. */
|
||||
private fun utf16z(b: ByteArray, off: Int, limit: Int = b.size): String {
|
||||
val sb = StringBuilder()
|
||||
var i = off
|
||||
val end = minOf(limit, b.size)
|
||||
while (i + 1 < end) {
|
||||
val c = u8(b, i) or (u8(b, i + 1) shl 8)
|
||||
if (c == 0) break
|
||||
sb.append(c.toChar())
|
||||
i += 2
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun u8(b: ByteArray, off: Int): Int = if (off < 0 || off >= b.size) 0 else b[off].toInt() and 0xFF
|
||||
private fun u16(b: ByteArray, off: Int): Int = u8(b, off) or (u8(b, off + 1) shl 8)
|
||||
private fun u32(b: ByteArray, off: Int): Long =
|
||||
u8(b, off).toLong() or (u8(b, off + 1).toLong() shl 8) or
|
||||
(u8(b, off + 2).toLong() shl 16) or (u8(b, off + 3).toLong() shl 24)
|
||||
|
||||
private fun read(raf: RandomAccessFile, pos: Long, len: Int): ByteArray? {
|
||||
if (len <= 0 || pos < 0) return null
|
||||
val fileLen = raf.length()
|
||||
if (pos >= fileLen) return null
|
||||
val n = minOf(len.toLong(), fileLen - pos).toInt()
|
||||
if (n <= 0) return null
|
||||
val buf = ByteArray(n)
|
||||
raf.seek(pos)
|
||||
raf.readFully(buf)
|
||||
return buf
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A Steam game whose download finishes during a session, noticed by its appmanifest (a new one,
|
||||
* or StateFlags turning to 4, fully installed) with a light look every five seconds while the
|
||||
* session runs, and handed to [AutoComponents] as a single job that may run in the session, so its
|
||||
* Windows components are in place when the user plays it right after the download.
|
||||
*/
|
||||
object SessionInstalls {
|
||||
private const val TAG = "SessionInstalls"
|
||||
private const val POLL_MS = 5_000L
|
||||
|
||||
/** One appmanifest as read: the app, its name and folder, whether it is fully installed. */
|
||||
class Manifest(val appId: Int, val name: String, val installDir: String, val installed: Boolean, val steamapps: File)
|
||||
|
||||
private val APPID = Regex("\"appid\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
private val NAME = Regex("\"name\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val STATE = Regex("\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
internal fun parse(file: File): Manifest? = runCatching {
|
||||
val text = file.readText()
|
||||
val id = APPID.find(text)?.groupValues?.get(1)?.toInt() ?: return null
|
||||
Manifest(id, NAME.find(text)?.groupValues?.get(1).orEmpty(), DIR.find(text)?.groupValues?.get(1).orEmpty(),
|
||||
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
|
||||
}.getOrNull()
|
||||
|
||||
/** The libraries the session's client installs to, as host folders. */
|
||||
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
|
||||
).filter { it.isDirectory }
|
||||
|
||||
/**
|
||||
* Keeps what the libraries hold between looks: a manifest re-read only when its time changed.
|
||||
* [poll] returns the apps that became fully installed since the last look (none on the first).
|
||||
*/
|
||||
internal class Watch(private val dirs: () -> List<File>) {
|
||||
private val seen = HashMap<String, Pair<Long, Manifest?>>()
|
||||
private var first = true
|
||||
|
||||
fun poll(): List<Manifest> {
|
||||
val fresh = ArrayList<Manifest>()
|
||||
for (dir in dirs()) for (f in dir.listFiles { x -> x.isFile && x.name.startsWith("appmanifest_") && x.name.endsWith(".acf") }.orEmpty()) {
|
||||
val before = seen[f.path]
|
||||
if (before != null && before.first == f.lastModified()) continue
|
||||
val now = parse(f)
|
||||
seen[f.path] = f.lastModified() to now
|
||||
if (!first && now != null && now.installed && before?.second?.installed != true) fresh.add(now)
|
||||
}
|
||||
first = false
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
/** A finished install as the Games tab would list it. */
|
||||
internal fun gameOf(m: Manifest): Library.SteamGame =
|
||||
Library.SteamGame(m.appId, m.name.ifEmpty { m.appId.toString() }, null, m.steamapps.parentFile?.path ?: "steam",
|
||||
gameFiles = File(m.steamapps, "common/${m.installDir}"), source = Library.SOURCE_STEAM)
|
||||
|
||||
/**
|
||||
* Looks every five seconds while the session runs, on a thread of its own at background
|
||||
* priority; each finished install becomes one job for its Windows components.
|
||||
*/
|
||||
fun watch(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
val watch = Watch { steamappsDirs(app) }
|
||||
try {
|
||||
// The session is still starting when this is called: up to two minutes for it.
|
||||
var waited = 0L
|
||||
while (!SessionState.running && waited < 120_000L) { Thread.sleep(POLL_MS); waited += POLL_MS }
|
||||
watch.poll()
|
||||
while (SessionState.running) {
|
||||
for (m in watch.poll()) {
|
||||
Log.i(TAG, "wincomp: ${m.name} (${m.appId}) finished installing in the session")
|
||||
AutoComponents.queueInSession(app, gameOf(m))
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "install watch: ${e.message}")
|
||||
}
|
||||
}, "session-installs").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Steam's own data for an app, from the client in the runtime: its app-info cache
|
||||
* (Steam/appcache/appinfo.vdf, the PICS data the client keeps for every app it has seen). What is
|
||||
* read is which Steamworks Shared depots (the VC++, DirectX... redistributables of app 228980) the
|
||||
* app's depots point at, and its name. Kept per appid in the app's files, so it works offline; an
|
||||
* app the client has not seen is looked for again after a week.
|
||||
*/
|
||||
object SteamAppInfo {
|
||||
private const val TAG = "SteamAppInfo"
|
||||
private const val CACHE = "steam-appinfo.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
/** The Steamworks Shared app the redistributable depots belong to. */
|
||||
const val SHARED_APP = "228980"
|
||||
|
||||
class Info(val name: String, val sharedDepots: List<String>)
|
||||
|
||||
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
|
||||
|
||||
/** The shared depots for [appId]: from the cache, else the client's app-info file; null when unknown. */
|
||||
@Synchronized
|
||||
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
|
||||
val cacheFile = AtomicFile(File(context.filesDir, CACHE))
|
||||
val cache = runCatching { JSONObject(cacheFile.readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfo.lastModified()
|
||||
val known = cache.optJSONObject(appId.toString())
|
||||
known?.let { o ->
|
||||
if (!o.optBoolean("miss")) return Info(o.optString("name"), o.optJSONArray("depots").strings())
|
||||
// A miss is read again when the client has written its cache since (the user may have
|
||||
// looked at the app in Steam), else a week later.
|
||||
if (now - o.optLong("at") < RETRY_AFTER_MS && o.optLong("mtime") == mtime) return null
|
||||
}
|
||||
val read = runCatching { read(appinfo, appId) }.onFailure { Log.w(TAG, "appinfo: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime)
|
||||
// When Steam was last asked anonymously ([lookup]), kept across re-reads of the file.
|
||||
known?.optLong("picsAt")?.takeIf { it > 0 }?.let { entry.put("picsAt", it) }
|
||||
if (read == null) entry.put("miss", true) else entry.put("name", read.name).put("depots", JSONArray(read.sharedDepots))
|
||||
cache.put(appId.toString(), entry)
|
||||
val out = cacheFile.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); cacheFile.finishWrite(out) } catch (e: Exception) { cacheFile.failWrite(out) }
|
||||
return read
|
||||
}
|
||||
|
||||
/** Steam asked anonymously ([SteamPics]); a test stands in for it. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal var pics: (List<Int>) -> Map<Int, Info?>? = { SteamPics.lookup(it) }
|
||||
|
||||
/**
|
||||
* Of [appIds], those neither the client's cache (appinfo.vdf, read first: free) nor an earlier
|
||||
* answer has, and that Steam was not asked about anonymously in the past week.
|
||||
*/
|
||||
fun needLookup(context: Context, appIds: List<Int>): List<Int> {
|
||||
val wanted = appIds.distinct().filter { it > 0 && info(context, it) == null }
|
||||
if (wanted.isEmpty()) return emptyList()
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
return wanted.filter { now - (cache.optJSONObject(it.toString())?.optLong("picsAt") ?: 0L) >= RETRY_AFTER_MS }
|
||||
}
|
||||
|
||||
/**
|
||||
* [appIds] the client never saw, asked of Steam anonymously in one connection ([SteamPics], in
|
||||
* batches) and every answer kept like the client's: an app's depots and name, or a miss tried
|
||||
* again in a week. False when nothing needed asking or Steam could not be reached. Blocking.
|
||||
*/
|
||||
@Synchronized
|
||||
fun lookup(context: Context, appIds: List<Int>): Boolean {
|
||||
val need = needLookup(context, appIds)
|
||||
if (need.isEmpty()) return false
|
||||
val answers = pics(need) ?: return false
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfoFile(context).lastModified()
|
||||
for (id in need) {
|
||||
val info = answers[id]
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime).put("picsAt", now)
|
||||
if (info == null) entry.put("miss", true) else entry.put("name", info.name).put("depots", JSONArray(info.sharedDepots)).put("source", "pics")
|
||||
cache.put(id.toString(), entry)
|
||||
}
|
||||
val file = AtomicFile(File(context.filesDir, CACHE))
|
||||
val out = file.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** What is kept for [appId]: "hit", "miss" or "none" (part of a game's list fingerprint). */
|
||||
fun state(context: Context, appId: Int): String = cache(context).optJSONObject(appId.toString())?.let { if (it.optBoolean("miss")) "miss" else "hit" } ?: "none"
|
||||
|
||||
private fun cache(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, CACHE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else (0 until length()).map { optString(it) }
|
||||
|
||||
/**
|
||||
* [appId]'s entry in an appinfo.vdf (versions 27, 28 and 29, the last with its keys in a string
|
||||
* table), or null when the file does not have it.
|
||||
*/
|
||||
internal fun read(file: File, appId: Int): Info? {
|
||||
if (!file.isFile) return null
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
raf.readFully(head.array(), 0, 8)
|
||||
val magic = head.getInt(0)
|
||||
val version = magic and 0xFF
|
||||
if (magic ushr 8 != 0x075644 || version !in 0x27..0x29) return null
|
||||
var strings: List<String>? = null
|
||||
var pos = 8L
|
||||
if (version >= 0x29) {
|
||||
raf.readFully(head.array(), 8, 8)
|
||||
val tableAt = head.getLong(8)
|
||||
strings = readStrings(raf, tableAt)
|
||||
pos = 16L
|
||||
}
|
||||
val fixed = if (version >= 0x28) 4 + 4 + 8 + 20 + 4 + 20 else 4 + 4 + 8 + 20 + 4
|
||||
val entry = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (pos + 8 <= raf.length()) {
|
||||
raf.seek(pos)
|
||||
raf.readFully(entry.array(), 0, 8)
|
||||
val id = entry.getInt(0)
|
||||
val size = entry.getInt(4).toLong() and 0xFFFFFFFFL
|
||||
if (id == 0) return null
|
||||
if (id == appId) {
|
||||
if (size > 64L * 1024 * 1024) return null
|
||||
val body = ByteArray(size.toInt())
|
||||
raf.readFully(body)
|
||||
val kv = ByteBuffer.wrap(body, fixed, body.size - fixed).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val tree = readMap(kv, strings)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = (tree["appinfo"] as? Map<String, Any>) ?: tree
|
||||
return infoOf(app)
|
||||
}
|
||||
pos += 8 + size
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun infoOf(app: Map<String, Any>): Info {
|
||||
val name = (app["common"] as? Map<String, Any>)?.get("name") as? String ?: ""
|
||||
val depots = (app["depots"] as? Map<String, Any>).orEmpty()
|
||||
val shared = depots.mapNotNull { (key, value) ->
|
||||
val depot = value as? Map<String, Any> ?: return@mapNotNull null
|
||||
key.takeIf { (depot["depotfromapp"] as? String) == SHARED_APP || (key in SteamRedists.DEPOTS && depot["sharedinstall"] != null) }
|
||||
}
|
||||
return Info(name, shared)
|
||||
}
|
||||
|
||||
private fun readStrings(raf: RandomAccessFile, at: Long): List<String> {
|
||||
raf.seek(at)
|
||||
val count = Integer.reverseBytes(raf.readInt())
|
||||
val rest = ByteArray((raf.length() - at - 4).toInt().coerceAtLeast(0))
|
||||
raf.readFully(rest)
|
||||
val out = ArrayList<String>(count)
|
||||
var start = 0
|
||||
for (i in rest.indices) {
|
||||
if (out.size == count) break
|
||||
if (rest[i] == 0.toByte()) { out.add(String(rest, start, i - start, Charsets.UTF_8)); start = i + 1 }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun cstring(b: ByteBuffer): String {
|
||||
val start = b.position()
|
||||
while (b.get() != 0.toByte()) { }
|
||||
val bytes = ByteArray(b.position() - start - 1)
|
||||
b.position(start); b.get(bytes); b.get()
|
||||
return String(bytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** One binary key-value map, to its end marker; keys by name, or by index into [strings]. */
|
||||
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (b.hasRemaining()) {
|
||||
val type = b.get().toInt() and 0xFF
|
||||
if (type == 0x08) return out
|
||||
val key = if (strings != null) strings.getOrElse(b.int) { "" } else cstring(b)
|
||||
when (type) {
|
||||
0x00 -> out[key] = readMap(b, strings)
|
||||
0x01 -> out[key] = cstring(b)
|
||||
0x02 -> out[key] = b.int.toString()
|
||||
0x03 -> out[key] = b.float.toString()
|
||||
0x07, 0x0A -> out[key] = b.long.toString()
|
||||
else -> return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.stores.CredentialCipher
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* SteamGridDB, the art source after Steam's store: a game by its Steam appid, else by a search
|
||||
* whose result must carry the same name, then its best-rated grids, heroes, logos and icons.
|
||||
*
|
||||
* The API key: the user's own (Steam settings › Games), sealed with [CredentialCipher] in the
|
||||
* app's files, else the one the build carries (`BuildConfig.SGDB_API_KEY`, from a CI secret), else
|
||||
* none, and then this step is skipped. The key travels only in the Authorization header and is
|
||||
* never logged.
|
||||
*/
|
||||
object SteamGridDb {
|
||||
private const val TAG = "SteamGridDb"
|
||||
private const val API = "https://www.steamgriddb.com/api/v2"
|
||||
private const val QUERY = "nsfw=false&humor=false"
|
||||
|
||||
@VisibleForTesting
|
||||
@Volatile
|
||||
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
|
||||
|
||||
private fun keyFile(context: Context) = File(context.filesDir, "sgdb/key.json")
|
||||
|
||||
/** The key the build carries, or "" (a build without the secret, a fork's PR build). */
|
||||
fun builtInKey(): String = runCatching {
|
||||
Class.forName("com.droiddeck.launcher.BuildConfig").getField("SGDB_API_KEY").get(null) as? String
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
/** The user's key wins, then the build's; "" when there is neither. */
|
||||
fun pick(user: String, builtIn: String): String = user.trim().ifEmpty { builtIn.trim() }
|
||||
|
||||
fun key(context: Context): String = pick(userKey(context), builtInKey())
|
||||
|
||||
/** The key the user entered, or "". */
|
||||
@Synchronized
|
||||
fun userKey(context: Context): String {
|
||||
val f = keyFile(context)
|
||||
if (!f.isFile) return ""
|
||||
return try {
|
||||
val json = JSONObject(f.readText())
|
||||
if (CredentialCipher.isEnvelope(json)) CredentialCipher.open(json, keys) else json.optString("key")
|
||||
} catch (e: Exception) {
|
||||
if (CredentialCipher.isPermanent(e)) f.delete()
|
||||
Log.w(TAG, "the saved key cannot be read (${e.javaClass.simpleName})")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves the user's key sealed (plain only where the Keystore does not work); "" clears it. */
|
||||
@Synchronized
|
||||
fun setUserKey(context: Context, key: String) {
|
||||
val f = keyFile(context)
|
||||
val value = key.trim()
|
||||
if (value.isEmpty()) { f.delete(); return }
|
||||
f.parentFile?.mkdirs()
|
||||
val text = try {
|
||||
CredentialCipher.seal(value, keys).toString()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "keystore unavailable, the key stays plain (${e.javaClass.simpleName})")
|
||||
JSONObject().put("key", value).toString()
|
||||
}
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
tmp.setReadable(false, false); tmp.setReadable(true, true)
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
|
||||
/** The SteamGridDB game id for [steamAppId], else for a search on [name] that returns the same name; null when neither. */
|
||||
fun gameId(key: String, steamAppId: Int?, name: String): Int? {
|
||||
if (steamAppId != null) {
|
||||
json(key, "$API/games/steam/$steamAppId")?.optJSONObject("data")?.optInt("id")?.takeIf { it > 0 }?.let { return it }
|
||||
}
|
||||
val term = URLEncoder.encode(name, "UTF-8").replace("+", "%20")
|
||||
val results = json(key, "$API/search/autocomplete/$term")?.optJSONArray("data") ?: return null
|
||||
return match(name, (0 until results.length()).map { results.getJSONObject(it) }.map { it.optInt("id") to it.optString("name") })
|
||||
}
|
||||
|
||||
/** The id of the first result whose name matches [name] ([AddedGameArt.nameMatch]); no blind first pick. */
|
||||
internal fun match(name: String, results: List<Pair<Int, String>>): Int? =
|
||||
results.map { (id, got) -> id to AddedGameArt.nameMatch(name, got) }
|
||||
.filter { it.second > 0 && it.first > 0 }
|
||||
.maxByOrNull { it.second }?.first
|
||||
|
||||
/**
|
||||
* Fetches what is missing into [cache] under the names Steam's art uses there (p.jpg,
|
||||
* header.jpg, hero.jpg, logo.png) and the icon as icon-sgdb.png. True when anything arrived.
|
||||
*/
|
||||
fun download(key: String, id: Int, cache: File): Boolean {
|
||||
var got = false
|
||||
for ((path, local) in listOf(
|
||||
"grids/game/$id?dimensions=600x900&$QUERY" to "p.jpg",
|
||||
"grids/game/$id?dimensions=460x215,920x430&$QUERY" to "header.jpg",
|
||||
"heroes/game/$id?$QUERY" to "hero.jpg",
|
||||
"logos/game/$id?$QUERY" to "logo.png",
|
||||
"icons/game/$id?$QUERY" to "icon-sgdb.png",
|
||||
)) {
|
||||
val dst = File(cache, local)
|
||||
if (dst.isFile) continue
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue
|
||||
val url = best(items, local.endsWith(".png")) ?: continue
|
||||
val bytes = AddedGameArt.get(url) ?: continue
|
||||
val tmp = File(cache, "$local.tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (tmp.renameTo(dst)) { got = true; File(cache, "$local.src").writeText(AddedGameArt.SGDB) }
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
/** The best-scored [limit] images for [slot] of game [id], as (thumbnail, full image) URLs. */
|
||||
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> {
|
||||
val path = when (slot) {
|
||||
AddedGameArt.Slot.COVER -> "grids/game/$id?dimensions=600x900&$QUERY"
|
||||
AddedGameArt.Slot.BACKGROUND -> "heroes/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.LOGO -> "logos/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.ICON -> "icons/game/$id?$QUERY"
|
||||
}
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: return emptyList()
|
||||
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON).take(limit)
|
||||
}
|
||||
|
||||
/** [items] best-scored first, as (thumbnail, full image) URLs; for a .png slot only PNGs. */
|
||||
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
|
||||
(0 until items.length()).map { items.getJSONObject(it) }
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
.sortedByDescending { it.optInt("score", 0) }
|
||||
.map { (it.optString("thumb").takeIf { t -> t.startsWith("https://") } ?: it.optString("url")) to it.optString("url") }
|
||||
|
||||
/** The highest-scored image's URL; for a .png slot only a PNG. */
|
||||
internal fun best(items: JSONArray, pngOnly: Boolean): String? =
|
||||
(0 until items.length()).map { items.getJSONObject(it) }
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
.maxByOrNull { it.optInt("score", 0) }?.optString("url")
|
||||
|
||||
/** A GET with the key in its Authorization header; the body as JSON, or null. Logs the path only. */
|
||||
private fun json(key: String, url: String): JSONObject? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
|
||||
c.setRequestProperty("Authorization", "Bearer $key")
|
||||
return try {
|
||||
if (c.responseCode != 200) { Log.w(TAG, "${URL(url).path}: HTTP ${c.responseCode}"); null }
|
||||
else JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}"); null
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which Steam app a game added in DroidDeck is, kept once per game (by its folder) and used by
|
||||
* everything that asks: its art ([AddedGameArt]) and its Windows components ([WinCompSources]),
|
||||
* so the two never disagree.
|
||||
*
|
||||
* [Certainty.FILES]: the game's own files name the appid (steam_appid.txt, also under
|
||||
* steam_settings/ or a parent folder; an emulator ini; Steam's appmanifest) - certain.
|
||||
* [Certainty.NAME]: Steam's store search returned a game of the same name - a guess.
|
||||
* [Certainty.NONE]: neither; a miss is tried again after a week.
|
||||
*/
|
||||
object SteamMatch {
|
||||
enum class Certainty { FILES, NAME, NONE }
|
||||
|
||||
/** [source]: what gave the appid (GameIdentifier's source, or "store search"). */
|
||||
class Match(val appId: Int?, val certainty: Certainty, val source: String, val checkedAt: Long)
|
||||
|
||||
private const val FILE = "steam-matches.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
private fun store(context: Context) = AtomicFile(File(context.filesDir, FILE))
|
||||
|
||||
@Synchronized
|
||||
private fun all(context: Context): JSONObject =
|
||||
runCatching { JSONObject(store(context).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, key: String): Match? = all(context).optJSONObject(key)?.let { o ->
|
||||
Match(
|
||||
o.optInt("appId", 0).takeIf { it > 0 },
|
||||
runCatching { Certainty.valueOf(o.optString("certainty")) }.getOrDefault(Certainty.NONE),
|
||||
o.optString("source"), o.optLong("at"),
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun put(context: Context, key: String, match: Match) {
|
||||
val json = all(context).put(
|
||||
key, JSONObject().put("certainty", match.certainty.name).put("source", match.source).put("at", match.checkedAt)
|
||||
.apply { match.appId?.let { put("appId", it) } },
|
||||
)
|
||||
val atomic = store(context)
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The match for the game in [key] (its folder), [exe] its exe and [name] its title: its files
|
||||
* first, always read again; else the one kept (a name match stays, a miss for a week); else
|
||||
* [legacy], an appid an earlier build's art lookup kept; else [search] on the name, when given.
|
||||
* Blocking ([search] asks the network).
|
||||
*/
|
||||
fun resolve(context: Context, key: String, exe: File, name: String, legacy: File? = null, search: ((String) -> Int?)? = null): Match {
|
||||
val now = System.currentTimeMillis()
|
||||
val identity = runCatching { GameIdentifier.identify(exe) }.getOrNull()
|
||||
identity?.appId?.let { id ->
|
||||
return Match(id, Certainty.FILES, identity.source.name.lowercase(), now).also { put(context, key, it) }
|
||||
}
|
||||
val known = get(context, key)
|
||||
if (known != null && known.certainty == Certainty.NAME) return known
|
||||
if (known != null && known.certainty == Certainty.NONE && now - known.checkedAt < RETRY_AFTER_MS) return known
|
||||
legacy?.takeIf { it.isFile }?.let { runCatching { it.readText().trim().toInt() }.getOrNull() }?.takeIf { it > 0 }?.let { id ->
|
||||
return Match(id, Certainty.NAME, "store search", now).also { put(context, key, it) }
|
||||
}
|
||||
if (search == null) return known?.takeIf { it.certainty != Certainty.FILES } ?: Match(null, Certainty.NONE, "", 0)
|
||||
val id = runCatching { search(name) }.getOrNull()
|
||||
val match = if (id != null && id > 0) Match(id, Certainty.NAME, "store search", now) else Match(null, Certainty.NONE, "", now)
|
||||
put(context, key, match)
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* Steam's own app info (PICS) for apps the client in the runtime has never seen, asked of Steam
|
||||
* anonymously: a connection to one of Steam's connection managers (the list from Steam's public
|
||||
* ISteamDirectory/GetCMListForConnect, the WebSocket transport Steam's web clients use), an
|
||||
* anonymous logon - no account, nothing of the user's Steam sign-in - a request for the apps'
|
||||
* access tokens and then their product info, the app KeyValues read for their Steamworks Shared
|
||||
* depots and name, and the connection closed. What steamcmd's anonymous `app_info_print` sees.
|
||||
*/
|
||||
object SteamPics {
|
||||
private const val TAG = "SteamPics"
|
||||
private const val CM_LIST = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&format=json"
|
||||
/** At most this many apps per request. */
|
||||
const val BATCH = 25
|
||||
private const val PROTO = 0x80000000.toInt()
|
||||
private const val EMSG_MULTI = 1
|
||||
private const val EMSG_LOGON = 5514
|
||||
private const val EMSG_LOGON_RESPONSE = 751
|
||||
private const val EMSG_LOGOFF = 706
|
||||
private const val EMSG_TOKENS_REQUEST = 8905
|
||||
private const val EMSG_TOKENS_RESPONSE = 8906
|
||||
private const val EMSG_INFO_REQUEST = 8903
|
||||
private const val EMSG_INFO_RESPONSE = 8904
|
||||
private const val PROTOCOL_VERSION = 65580
|
||||
/** An anonymous user's SteamID: universe Public, account type AnonUser, instance 0. */
|
||||
private const val ANON_STEAMID = (1L shl 56) or (10L shl 52)
|
||||
|
||||
/**
|
||||
* [appIds]' info, one connection for all of them in batches of [BATCH]: each known app's
|
||||
* info, null for an app Steam does not know. Null when Steam could not be reached. Blocking.
|
||||
*/
|
||||
fun lookup(appIds: List<Int>): Map<Int, SteamAppInfo.Info?>? {
|
||||
if (appIds.isEmpty()) return emptyMap()
|
||||
val host = runCatching { cmHost() }.getOrNull() ?: return null
|
||||
return try {
|
||||
Wss(host, 20_000).use { ws ->
|
||||
ws.send(message(EMSG_LOGON, header(ANON_STEAMID, 0), Proto().varint(1, PROTOCOL_VERSION.toLong()).varint(7, 16).string(6, "english").bytes()))
|
||||
var steamId = 0L
|
||||
var session = 0
|
||||
// The logon's answer (other messages before it are passed over).
|
||||
loop@ while (true) for ((emsg, hdr, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_LOGON_RESPONSE) {
|
||||
val h = fields(hdr); val b = fields(body)
|
||||
if ((b[1]?.firstOrNull() as? Long ?: 0L) != 1L) { Log.w(TAG, "anonymous logon refused: ${b[1]}"); return null }
|
||||
steamId = h[1]?.firstOrNull() as? Long ?: 0L
|
||||
session = (h[2]?.firstOrNull() as? Long ?: 0L).toInt()
|
||||
break@loop
|
||||
}
|
||||
val out = HashMap<Int, SteamAppInfo.Info?>()
|
||||
for (batch in batches(appIds)) {
|
||||
val tokens = HashMap<Int, Long>()
|
||||
ws.send(message(EMSG_TOKENS_REQUEST, header(steamId, session), Proto().apply { batch.forEach { varint(2, it.toLong()) } }.bytes()))
|
||||
tokens@ while (true) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_TOKENS_RESPONSE) {
|
||||
for (t in fields(body)[3].orEmpty()) {
|
||||
val f = fields(t as ByteArray)
|
||||
val id = (f[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
tokens[id] = f[2]?.firstOrNull() as? Long ?: 0L
|
||||
}
|
||||
break@tokens
|
||||
}
|
||||
ws.send(message(EMSG_INFO_REQUEST, header(steamId, session), Proto().apply {
|
||||
batch.forEach { id -> bytes(2, Proto().varint(1, id.toLong()).apply { tokens[id]?.takeIf { it != 0L }?.let { varint(2, it) } }.bytes()) }
|
||||
varint(3, 0)
|
||||
}.bytes()))
|
||||
var pending = true
|
||||
while (pending) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_INFO_RESPONSE) {
|
||||
val r = parseInfoResponse(body)
|
||||
out.putAll(r.apps)
|
||||
r.unknown.forEach { out[it] = null }
|
||||
pending = r.pending
|
||||
}
|
||||
batch.forEach { if (it !in out) out[it] = null }
|
||||
}
|
||||
runCatching { ws.send(message(EMSG_LOGOFF, header(steamId, session), ByteArray(0))) }
|
||||
Log.i(TAG, "steam pics: ${out.size} apps, ${out.values.count { it?.sharedDepots?.isNotEmpty() == true }} with shared redists")
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam pics: ${e.javaClass.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** [appIds] in requests of at most [BATCH], each once. */
|
||||
fun batches(appIds: List<Int>): List<List<Int>> = appIds.distinct().chunked(BATCH)
|
||||
|
||||
/** A connection manager for the WebSocket transport, least loaded first as Steam lists them. */
|
||||
private fun cmHost(): String? {
|
||||
val c = URL(CM_LIST).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000; c.readTimeout = 10_000
|
||||
val body = try { c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
||||
val list = JSONObject(body).optJSONObject("response")?.optJSONArray("serverlist") ?: return null
|
||||
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("type") == "websockets" }?.optString("endpoint")?.substringBefore(':')
|
||||
}
|
||||
|
||||
// ---- the product info answer ----
|
||||
|
||||
class InfoResponse(val apps: Map<Int, SteamAppInfo.Info>, val unknown: List<Int>, val pending: Boolean)
|
||||
|
||||
/** A CMsgClientPICSProductInfoResponse: each app's KeyValues read, the unknown ones, whether more follows. */
|
||||
@VisibleForTesting
|
||||
internal fun parseInfoResponse(body: ByteArray): InfoResponse {
|
||||
val f = fields(body)
|
||||
val apps = HashMap<Int, SteamAppInfo.Info>()
|
||||
for (a in f[1].orEmpty()) {
|
||||
val app = fields(a as ByteArray)
|
||||
val id = (app[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
val buffer = app[5]?.firstOrNull() as? ByteArray ?: continue
|
||||
val text = String(buffer, Charsets.UTF_8).trimEnd('\u0000')
|
||||
infoFromKeyValues(text)?.let { apps[id] = it }
|
||||
}
|
||||
val unknown = f[2].orEmpty().mapNotNull { (it as? Long)?.toInt() } + f[2].orEmpty().filterIsInstance<ByteArray>().flatMap { packed(it) }
|
||||
return InfoResponse(apps, unknown, (f[6]?.firstOrNull() as? Long ?: 0L) != 0L)
|
||||
}
|
||||
|
||||
/** An app's KeyValues text ("appinfo" { "common" {...} "depots" {...} }) to its name and shared depots. */
|
||||
@VisibleForTesting
|
||||
internal fun infoFromKeyValues(text: String): SteamAppInfo.Info? {
|
||||
// The buffer ends in a NUL, kept or not by whoever handed it over.
|
||||
val tree = runCatching { KeyValues(text.trimEnd('\u0000', ' ', '\n', '\r', '\t')).read() }.getOrNull() ?: return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = tree["appinfo"] as? Map<String, Any> ?: return null
|
||||
return SteamAppInfo.infoOf(app)
|
||||
}
|
||||
|
||||
/** Valve's text KeyValues: quoted keys and values, nested braces. */
|
||||
private class KeyValues(private val s: String) {
|
||||
private var i = 0
|
||||
fun read(): Map<String, Any> = map(top = true)
|
||||
private fun map(top: Boolean): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (true) {
|
||||
skip()
|
||||
if (i >= s.length) { if (top) return out else throw IOException("unclosed") }
|
||||
if (s[i] == '}') { i++; return out }
|
||||
val key = token()
|
||||
skip()
|
||||
if (i < s.length && s[i] == '{') { i++; out[key] = map(top = false) } else out[key] = token()
|
||||
}
|
||||
}
|
||||
private fun skip() {
|
||||
while (i < s.length) {
|
||||
if (s[i].isWhitespace() || s[i] == '\u0000') i++
|
||||
else if (s.startsWith("//", i)) { while (i < s.length && s[i] != '\n') i++ }
|
||||
else break
|
||||
}
|
||||
}
|
||||
private fun token(): String {
|
||||
if (i < s.length && s[i] == '"') {
|
||||
i++
|
||||
val b = StringBuilder()
|
||||
while (i < s.length && s[i] != '"') {
|
||||
if (s[i] == '\\' && i + 1 < s.length) { i++; b.append(when (s[i]) { 'n' -> '\n'; 't' -> '\t'; else -> s[i] }) } else b.append(s[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
return b.toString()
|
||||
}
|
||||
val start = i
|
||||
while (i < s.length && !s[i].isWhitespace() && s[i] != '{' && s[i] != '}') i++
|
||||
if (start == i) throw IOException("token expected at $i")
|
||||
return s.substring(start, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
private fun header(steamId: Long, session: Int) = Proto().fixed64(1, steamId).varint(2, session.toLong()).bytes()
|
||||
|
||||
private fun message(emsg: Int, header: ByteArray, body: ByteArray): ByteArray =
|
||||
ByteBuffer.allocate(8 + header.size + body.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putInt(emsg or PROTO).putInt(header.size).put(header).put(body).array()
|
||||
|
||||
/** One message off the wire as (emsg, header, body); a Multi unpacked (gunzipped when it says so) into the ones inside. */
|
||||
@VisibleForTesting
|
||||
internal fun messages(data: ByteArray): List<Triple<Int, ByteArray, ByteArray>> {
|
||||
if (data.size < 8) return emptyList()
|
||||
val b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val raw = b.int
|
||||
if (raw and PROTO == 0) return emptyList()
|
||||
val emsg = raw and PROTO.inv()
|
||||
val hl = b.int
|
||||
if (hl < 0 || 8 + hl > data.size) return emptyList()
|
||||
val header = data.copyOfRange(8, 8 + hl)
|
||||
val body = data.copyOfRange(8 + hl, data.size)
|
||||
if (emsg != EMSG_MULTI) return listOf(Triple(emsg, header, body))
|
||||
val f = fields(body)
|
||||
var inner = f[2]?.firstOrNull() as? ByteArray ?: return emptyList()
|
||||
if ((f[1]?.firstOrNull() as? Long ?: 0L) > 0) inner = GZIPInputStream(ByteArrayInputStream(inner)).use { it.readBytes() }
|
||||
val out = ArrayList<Triple<Int, ByteArray, ByteArray>>()
|
||||
val ib = ByteBuffer.wrap(inner).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (ib.remaining() >= 4) {
|
||||
val len = ib.int
|
||||
if (len < 0 || len > ib.remaining()) break
|
||||
val one = ByteArray(len).also { ib.get(it) }
|
||||
out.addAll(messages(one))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- protobuf, the little needed ----
|
||||
|
||||
/** Writes protobuf fields. */
|
||||
@VisibleForTesting
|
||||
internal class Proto {
|
||||
private val out = ByteArrayOutputStream()
|
||||
private fun raw(v: Long) { var x = v; while (true) { val b = (x and 0x7F).toInt(); x = x ushr 7; if (x == 0L) { out.write(b); return }; out.write(b or 0x80) } }
|
||||
fun varint(field: Int, v: Long) = apply { raw((field shl 3).toLong()); raw(v) }
|
||||
fun fixed64(field: Int, v: Long) = apply { raw(((field shl 3) or 1).toLong()); for (k in 0 until 8) out.write(((v ushr (8 * k)) and 0xFF).toInt()) }
|
||||
fun bytes(field: Int, b: ByteArray) = apply { raw(((field shl 3) or 2).toLong()); raw(b.size.toLong()); out.write(b) }
|
||||
fun string(field: Int, s: String) = bytes(field, s.toByteArray())
|
||||
fun bytes(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
/** Reads protobuf fields: number to values (Long for varint and fixed, ByteArray for length-delimited). */
|
||||
@VisibleForTesting
|
||||
internal fun fields(b: ByteArray): Map<Int, List<Any>> {
|
||||
val out = HashMap<Int, MutableList<Any>>()
|
||||
var i = 0
|
||||
fun varint(): Long { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) return v } }
|
||||
while (i < b.size) {
|
||||
val key = varint()
|
||||
val field = (key ushr 3).toInt()
|
||||
val value: Any = when ((key and 7).toInt()) {
|
||||
0 -> varint()
|
||||
1 -> { var v = 0L; for (k in 0 until 8) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 8; v }
|
||||
5 -> { var v = 0L; for (k in 0 until 4) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 4; v }
|
||||
2 -> { val n = varint().toInt(); val v = b.copyOfRange(i, i + n); i += n; v }
|
||||
else -> throw IOException("wire type ${key and 7}")
|
||||
}
|
||||
out.getOrPut(field) { ArrayList() }.add(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun packed(b: ByteArray): List<Int> {
|
||||
val out = ArrayList<Int>(); var i = 0
|
||||
while (i < b.size) { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) break }; out.add(v.toInt()) }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- WebSocket over TLS, binary frames ----
|
||||
|
||||
private class Wss(host: String, timeoutMs: Int) : AutoCloseable {
|
||||
// Made with the host name, so it goes out as SNI.
|
||||
private val socket: SSLSocket = (SSLSocketFactory.getDefault().createSocket(host, 443) as SSLSocket).apply { soTimeout = timeoutMs }
|
||||
private val out: OutputStream
|
||||
private val input: DataInputStream
|
||||
|
||||
init {
|
||||
// Host name checked against the certificate, as HTTPS does.
|
||||
socket.sslParameters = socket.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
|
||||
socket.startHandshake()
|
||||
out = socket.outputStream
|
||||
input = DataInputStream(socket.inputStream)
|
||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
out.write(("GET /cmsocket/ HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: ${android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
|
||||
out.flush()
|
||||
val status = line(input) ?: throw IOException("no upgrade response")
|
||||
if (!status.contains(" 101 ")) throw IOException("upgrade refused: $status")
|
||||
while (true) { val l = line(input) ?: break; if (l.isEmpty()) break }
|
||||
}
|
||||
|
||||
private fun line(s: InputStream): String? {
|
||||
val buf = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b = s.read()
|
||||
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
|
||||
buf.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(payload: ByteArray) {
|
||||
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
|
||||
val frame = ByteArrayOutputStream()
|
||||
frame.write(0x82)
|
||||
when {
|
||||
payload.size < 126 -> frame.write(0x80 or payload.size)
|
||||
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
|
||||
else -> { frame.write(0x80 or 127); for (k in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * k)) and 0xFF).toInt()) }
|
||||
}
|
||||
frame.write(mask)
|
||||
for (k in payload.indices) frame.write((payload[k].toInt() xor mask[k % 4].toInt()) and 0xFF)
|
||||
out.write(frame.toByteArray()); out.flush()
|
||||
}
|
||||
|
||||
/** The next binary message (fragments joined); null on close. */
|
||||
fun receive(): ByteArray? {
|
||||
val message = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b0 = input.read(); if (b0 < 0) return null
|
||||
val b1 = input.read(); if (b1 < 0) return null
|
||||
val fin = b0 and 0x80 != 0
|
||||
val opcode = b0 and 0x0F
|
||||
var len = (b1 and 0x7F).toLong()
|
||||
if (len == 126L) len = input.readUnsignedShort().toLong() else if (len == 127L) len = input.readLong()
|
||||
if (len > 32L * 1024 * 1024) throw IOException("frame too large")
|
||||
val data = ByteArray(len.toInt()).also { input.readFully(it) }
|
||||
when (opcode) {
|
||||
0x8 -> return null
|
||||
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
0x2, 0x0 -> { message.write(data); if (fin) return message.toByteArray() }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.epic.EpicApiClient
|
||||
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
|
||||
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
|
||||
import com.droiddeck.launcher.stores.gog.GogAuth
|
||||
import com.droiddeck.launcher.stores.gog.GogDownloadManager
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* For a GOG or Epic game installed before its sidecar kept the store's list (GOG's build
|
||||
* `dependencies`, Epic's manifest prerequisite): the list read once from the store's build
|
||||
* manifest with the saved sign-in - the manifest alone, never a game file - and kept in the sidecar
|
||||
* the way an install keeps it. Runs on the components' worker ([AutoComponents]). Without a
|
||||
* sign-in or a network it is skipped quietly and tried again a week later.
|
||||
*/
|
||||
object StoreListBackfill {
|
||||
private const val TAG = "StoreListBackfill"
|
||||
private const val FILE = "store-list-backfill.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
/** The store's list for the game in a sidecar: GOG's dependency ids, or Epic's prerequisite {name, path}; null when it could not be had. */
|
||||
fun interface Fetcher {
|
||||
fun fetch(context: Context, sidecar: StoreGameSidecar): Map<String, String>?
|
||||
}
|
||||
|
||||
/** The store itself, with the saved sign-in. */
|
||||
internal val storeFetcher = Fetcher { context, sidecar -> fromStore(context, sidecar) }
|
||||
|
||||
@VisibleForTesting internal var fetcher: Fetcher = storeFetcher
|
||||
|
||||
/** Whether [sidecar] was written before the list was kept. */
|
||||
/**
|
||||
* Whether [sidecar] has no list read whole: written before the list was kept, or by a build
|
||||
* that could have written an empty one for a manifest it did not read right.
|
||||
*/
|
||||
fun needs(sidecar: StoreGameSidecar): Boolean =
|
||||
(sidecar.store == Store.GOG || sidecar.store == Store.EPIC) && WinCompSources.LIST_READ !in sidecar.extra
|
||||
|
||||
/**
|
||||
* The list for the game in [folder], fetched and kept when its sidecar lacks one and it was not
|
||||
* tried in the past week. True when the sidecar changed. Blocking (network).
|
||||
*/
|
||||
@Synchronized
|
||||
fun fill(context: Context, folder: File): Boolean {
|
||||
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled && needs(it) } ?: return false
|
||||
val tries = runCatching { JSONObject(AtomicFile(File(context.filesDir, FILE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - tries.optLong(folder.path, 0L) < RETRY_AFTER_MS) return false
|
||||
// Offline is no try: it waits for a network rather than a week.
|
||||
if (!AutoComponents.online(context)) return false
|
||||
val list = runCatching { fetcher.fetch(context, sidecar) }.getOrNull()
|
||||
if (list == null) {
|
||||
tries.put(folder.path, now)
|
||||
val atomic = AtomicFile(File(context.filesDir, FILE))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(tries.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
Log.i(TAG, "${sidecar.title}: the ${sidecar.store.label} list could not be had; again in a week")
|
||||
return false
|
||||
}
|
||||
// Read fresh and written whole, as an install writes it: only the list is added.
|
||||
val current = StoreGameSidecar.read(folder) ?: return false
|
||||
current.copy(extra = current.extra + list + (WinCompSources.LIST_READ to "1")).write(folder)
|
||||
Log.i(TAG, "backfill: ${sidecar.title} " + when (sidecar.store) {
|
||||
Store.GOG -> "gog deps=[${list[WinCompSources.GOG_DEPENDENCIES].orEmpty()}]"
|
||||
else -> "epic prereq=" + (list[WinCompSources.EPIC_PREREQ_NAME].orEmpty().ifEmpty { list[WinCompSources.EPIC_PREREQ_PATH].orEmpty() }.ifEmpty { "none" })
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private fun fromStore(context: Context, sidecar: StoreGameSidecar): Map<String, String>? {
|
||||
return when (sidecar.store) {
|
||||
Store.GOG -> {
|
||||
val token = GogAuth.validToken(context) ?: return null
|
||||
GogDownloadManager.fetchDependencies(sidecar.id, sidecar.installVersion, token)
|
||||
?.let { mapOf(WinCompSources.GOG_DEPENDENCIES to it.joinToString(",")) }
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
|
||||
val namespace = sidecar.extra["namespace"].orEmpty()
|
||||
val catalogItemId = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (namespace.isEmpty() || catalogItemId.isEmpty()) return null
|
||||
val json = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, sidecar.extra["appName"] ?: sidecar.id) ?: return null
|
||||
EpicDownloadManager.fetchPrereq(json)?.let { (name, path) ->
|
||||
mapOf(WinCompSources.EPIC_PREREQ_NAME to name, WinCompSources.EPIC_PREREQ_PATH to path)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Which Windows components a game needs, from the list its source keeps - the source badge decides
|
||||
* which list is trusted:
|
||||
*
|
||||
* - **Steam**: the Steamworks Shared redistributables Steam installs with the game (its
|
||||
* appmanifest's SharedDepots, else the client's app info) - turned on by themselves.
|
||||
* - **GOG**: the build manifest's `dependencies`, kept in the sidecar at install - on by themselves.
|
||||
* - **Epic Games**: the manifest's prerequisite (usually UE4/UE5's prerequisite setup: VC++ and the
|
||||
* DirectX June 2010 runtime), kept in the sidecar at install - on by themselves.
|
||||
* - **Amazon Games**: fuel.json's PostInstall installers - on by themselves.
|
||||
* - **Custom** whose files name a Steam appid ([SteamMatch], certain): Steam's list for that app -
|
||||
* on by themselves. Matched by name only: the same list as Recommended. No match: the folder scan
|
||||
* ([DependencyDetector]) as Recommended.
|
||||
*
|
||||
* Every store entry is mapped to catalog names here, in one place; one that maps to nothing is a
|
||||
* suggestion under its own name. The folder scan is offered as Recommended for every game.
|
||||
*/
|
||||
object WinCompSources {
|
||||
/** Whose list a finding came from; the reason line says it. */
|
||||
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
|
||||
|
||||
/**
|
||||
* One thing a list names: [component] the catalog name it maps to (null: none, a suggestion),
|
||||
* [original] what the list called it, [auto] whether it is turned on by itself, [args] what
|
||||
* the reason line names (Steam: the label, the game, the appid).
|
||||
*/
|
||||
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
|
||||
|
||||
// ---- mapping tables: a store's names -> catalog components ----
|
||||
|
||||
private val VC_YEAR = mapOf(
|
||||
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010", "2012" to "vcredist2012",
|
||||
"2013" to "vcredist2013", "2015" to "vcredist2015", "2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
|
||||
)
|
||||
private val DOTNET = mapOf(
|
||||
"35" to "dotnet35", "40" to "dotnet40", "45" to "dotnet45", "451" to "dotnet452", "452" to "dotnet452",
|
||||
"46" to "dotnet46", "461" to "dotnet46", "462" to "dotnet46", "47" to "dotnet472", "471" to "dotnet472",
|
||||
"472" to "dotnet472", "48" to "dotnet48", "481" to "dotnet48",
|
||||
)
|
||||
|
||||
/**
|
||||
* A GOG dependency id ("MSVC2017_x64", "DirectX", "DOTNET48", "PhysX", "OpenAL", "XNA4") to its
|
||||
* components; empty for one that is none of ours (ISI scripts, GOG's own helpers).
|
||||
*/
|
||||
fun gog(id: String): List<String> {
|
||||
val key = id.uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
|
||||
Regex("""^(?:MSVC|VCREDIST|VC)(\d{4})""").find(key)?.let { m -> return listOfNotNull(VC_YEAR[m.groupValues[1]]) }
|
||||
Regex("""^(?:DOTNET|NETFRAMEWORK|DOTNETFRAMEWORK)(\d{2,3})""").find(key)?.let { m -> return listOfNotNull(DOTNET[m.groupValues[1]]) }
|
||||
return when {
|
||||
key.startsWith("DIRECTX") || key.startsWith("DX9") || key == "DXSETUP" || key.startsWith("D3DX") -> listOf("d3dx9")
|
||||
key.startsWith("PHYSX") -> listOf("physx")
|
||||
key.startsWith("OPENAL") -> listOf("oalinst")
|
||||
key == "XNA31" || key == "XNA3" -> listOf("xna31")
|
||||
key.startsWith("XNA") -> listOf("xna40")
|
||||
key.startsWith("XLIVE") || key.startsWith("GFWL") -> listOf("XLiveRedist")
|
||||
key.startsWith("XACT") -> listOf("xact")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Unreal's prerequisite setup (UE4PrereqSetup_x64.exe, UEPrereqSetup_x64.exe): VC++ 2015-2022 and DirectX June 2010. */
|
||||
private val UE_PREREQ = Regex("""(?i)ue\d?prereqsetup""")
|
||||
|
||||
/** An Epic prerequisite ([path] the installer it runs, [name] what it is called) to its components. */
|
||||
fun epic(path: String, name: String): List<String> = when {
|
||||
UE_PREREQ.containsMatchIn(path) || name.contains("UE4 Prerequisites", true) || name.contains("UE5 Prerequisites", true) ||
|
||||
name.contains("Unreal Engine Prerequisites", true) -> listOf("vcredist2022", "d3dx9")
|
||||
else -> DependencyDetector.componentsFor(path).ifEmpty { if (path.isEmpty()) gog(name) else emptyList() }
|
||||
}
|
||||
|
||||
/** An installer a store runs after an install (Amazon's PostInstall "Command") to its components. */
|
||||
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
|
||||
|
||||
/** Steamworks Shared depot ids to (component, Steam's label). */
|
||||
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
|
||||
|
||||
// ---- what each store left at install ----
|
||||
|
||||
/** Sidecar keys the store installs fill. */
|
||||
const val GOG_DEPENDENCIES = "dependencies"
|
||||
const val EPIC_PREREQ_PATH = "prereqPath"
|
||||
const val EPIC_PREREQ_NAME = "prereqName"
|
||||
/** Set beside the list when it was read whole (an empty list then means the store lists none). */
|
||||
const val LIST_READ = "listRead"
|
||||
|
||||
/** fuel.json's PostInstall commands. */
|
||||
fun amazonPostInstall(folder: File): List<String> = runCatching {
|
||||
val arr = JSONObject(File(folder, "fuel.json").readText()).optJSONArray("PostInstall") ?: return emptyList()
|
||||
(0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("Command")?.takeIf { c -> c.isNotEmpty() } }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
// ---- per game ----
|
||||
|
||||
/** Steam's list for [appId]: the local appmanifest's SharedDepots, else the client's app info. */
|
||||
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
|
||||
val local = SteamRedists.detect(gameDir, appId)
|
||||
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
|
||||
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
|
||||
return pairs.map { (component, label) ->
|
||||
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything known for [game], blocking (files; the client's app info; no network). */
|
||||
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
|
||||
val folder = game.gameFiles
|
||||
val out = ArrayList<Finding>()
|
||||
val store = Store.byId(game.source)
|
||||
val sidecar = folder?.let { StoreGameSidecar.read(it) }
|
||||
when {
|
||||
game.library != Library.ADDED -> if (game.appId > 0) out += steamList(context, folder, game.appId, game.name, auto = true)
|
||||
store == Store.GOG -> sidecar?.extra?.get(GOG_DEPENDENCIES)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { id ->
|
||||
val parts = gog(id)
|
||||
if (parts.isEmpty()) out += Finding(null, id, Origin.GOG, false)
|
||||
parts.forEach { out += Finding(it, id, Origin.GOG, true) }
|
||||
}
|
||||
store == Store.EPIC -> {
|
||||
val path = sidecar?.extra?.get(EPIC_PREREQ_PATH).orEmpty()
|
||||
val name = sidecar?.extra?.get(EPIC_PREREQ_NAME).orEmpty()
|
||||
if (path.isNotEmpty() || name.isNotEmpty()) {
|
||||
val parts = epic(path, name)
|
||||
val original = name.ifEmpty { path.substringAfterLast('/') }
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.EPIC, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
|
||||
}
|
||||
}
|
||||
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
|
||||
val parts = installer(command)
|
||||
val original = command.replace('\\', '/').substringAfterLast('/')
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// A game added in DroidDeck (Custom, or a store's) that is a Steam app: Steam's list for it,
|
||||
// on by itself when its files say so, else only recommended.
|
||||
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
|
||||
val id = match.appId ?: return@let
|
||||
when (match.certainty) {
|
||||
SteamMatch.Certainty.FILES -> out += steamList(context, folder, id, game.name, auto = true)
|
||||
SteamMatch.Certainty.NAME -> out += steamList(context, folder, id, game.name, auto = false)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// The folder scan, for every game: what it bundles, as Recommended.
|
||||
folder?.let { DependencyDetector.detect(it) }?.forEach { rec ->
|
||||
out += Finding(rec.componentName, rec.reason, Origin.FOLDER, false, listOf(rec.reason, rec.kind.name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import java.util.zip.ZipInputStream;
|
||||
/**
|
||||
* Imported LINUX Vulkan drivers: glibc Turnip ICDs ({@code Turnip-<tag>[-variant]-Linux.zip} from
|
||||
* Banners-Turnip) for the runtime - the gamescope session that runs Valve's native ARM64 Steam
|
||||
* client, and the labwc desktop beside it. This is the driver that DRAWS there: the client's own UI
|
||||
* client, and the KDE Plasma desktop beside it. This is the driver that DRAWS there: the client's own UI
|
||||
* (OpenGL through the runtime's Zink), every game the client launches (D3D through Proton's
|
||||
* DXVK/VKD3D) and everything on the desktop. Putting the frame on the screen stays the Android
|
||||
* driver's job, in the app's compositor ({@link TurnipDriver}).
|
||||
|
||||
@@ -168,7 +168,7 @@ object Lossless {
|
||||
val libraries = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
|
||||
GameStorage.effective(context)?.path?.let(::File),
|
||||
) + SessionPrefs.addedGamesDirs(context).map(::File)
|
||||
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
|
||||
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,16 +10,23 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +47,7 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
immersive()
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(0xFF0B0F14.toInt())
|
||||
@@ -48,19 +56,27 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
val bar = ComposeView(this).apply {
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
|
||||
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
|
||||
snap = !snap
|
||||
ControllerPrefs.setSnap(context, snap)
|
||||
controls.setSnap(snap)
|
||||
}
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,14 +87,27 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorButton(label: String, primary: Boolean, onClick: () -> Unit) {
|
||||
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
|
||||
color = when {
|
||||
primary -> pal.onSignal
|
||||
selected == true -> pal.signal
|
||||
else -> MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
modifier = Modifier.clip(RoundedCornerShape(50))
|
||||
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick)
|
||||
.background(
|
||||
when {
|
||||
primary -> pal.signal
|
||||
selected == true -> pal.signal.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (selected == null) Modifier.clickable(onClick = onClick)
|
||||
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ object ControllerPrefs {
|
||||
val steamButton: Boolean,
|
||||
val qamButton: Boolean,
|
||||
val keyboardButton: Boolean,
|
||||
val selectSteam: Boolean,
|
||||
val snap: Boolean,
|
||||
val customLayout: Boolean,
|
||||
val mapping: Map<String, String>,
|
||||
)
|
||||
@@ -64,6 +66,8 @@ object ControllerPrefs {
|
||||
steamButton = p.getBoolean("steamButton", true),
|
||||
qamButton = p.getBoolean("qamButton", true),
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
snap = p.getBoolean("snap", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
)
|
||||
@@ -119,6 +123,15 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
|
||||
/** The physical Select button opens the Steam menu instead of View/Select. */
|
||||
fun setSelectSteam(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("selectSteam", on).apply()
|
||||
}
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
|
||||
fun layout(context: Context, width: Int, height: Int): Map<String, Pair<Float, Float>> {
|
||||
val raw = prefs(context).getString("layout.${width}x$height", null) ?: return emptyMap()
|
||||
return raw.split(';').mapNotNull { entry ->
|
||||
|
||||
@@ -94,18 +94,52 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
|
||||
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
|
||||
// native BLE reports and output reports, so the Steam client can see it as its own device.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
|
||||
private static final int IMU_MODE_OFFSET = IMU_OFFSET + 4;
|
||||
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
|
||||
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
|
||||
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
// Slot 0 only: what the pad is to the Steam client (libfakeinput's PAD_TYPE_*), | PAD_TYPE_SET.
|
||||
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
|
||||
private static final int PAD_TYPE_SET = 0x80000000;
|
||||
public static final int PAD_TYPE_DECK = 0;
|
||||
public static final int PAD_TYPE_STEAM = 1;
|
||||
public static final int PAD_TYPE_XBOX = 2;
|
||||
public static final int PAD_TYPE_DS4 = 3;
|
||||
public static final int PAD_TYPE_DUALSENSE = 4;
|
||||
/** The type chosen this process, written into a ring made after the choice too; -1 for none. */
|
||||
private static volatile int padType = -1;
|
||||
/** The paired controller's state, likewise written into a ring made after it connected. */
|
||||
private static volatile boolean tritonConnected = false;
|
||||
private static final int TRITON_OFFSET = RING_SIZE + 64;
|
||||
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
|
||||
// Whether the paired controller is connected: libfakeinput offers its device only then.
|
||||
private static final int TRITON_CONNECTED_OFFSET = TRITON_OFFSET + 4;
|
||||
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
|
||||
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
|
||||
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
|
||||
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
|
||||
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
|
||||
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
|
||||
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
|
||||
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
|
||||
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
|
||||
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
|
||||
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
|
||||
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
|
||||
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
|
||||
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
|
||||
private static final int TRITON_REPORT_MAX = 64;
|
||||
private static final int TRITON_BLOCK_SIZE = 256;
|
||||
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -215,11 +249,24 @@ public class FakeInputWriter {
|
||||
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(IMU_OFFSET, 0);
|
||||
data.putInt(IMU_MODE_OFFSET, 0);
|
||||
data.putLong(IMU_SEQ_OFFSET, 0L);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
int type = padType;
|
||||
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
|
||||
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
|
||||
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
|
||||
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
|
||||
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
|
||||
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
|
||||
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
@@ -476,6 +523,23 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The motion the Steam client asked the Deck controller of slot {@code slot} for: its
|
||||
* SETTING_IMU_MODE (0 = off), or -1 until it has said. Safe from any thread.
|
||||
*/
|
||||
public static int readImuMode(int slot) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return -1;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
int value = ring == null ? 0 : ring.getInt(IMU_MODE_OFFSET);
|
||||
return value < 0 ? value & 0xffff : -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
|
||||
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
|
||||
@@ -502,6 +566,121 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
|
||||
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
|
||||
*/
|
||||
public static void setPadType(int type) {
|
||||
padType = type;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = RING_SLOTS[0];
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(PAD_TYPE_OFFSET, PAD_TYPE_SET | type);
|
||||
}
|
||||
}
|
||||
|
||||
/** The paired Steam Controller came or went (SteamControllerBle): its device is plugged in or out. */
|
||||
public static void setTritonConnected(int slot, boolean connected) {
|
||||
tritonConnected = connected;
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_CONNECTED_OFFSET, connected ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes a native Triton input report (without the HID report ID byte). */
|
||||
public static void writeTritonState(int slot, int reportId, byte[] report) {
|
||||
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0) seq++;
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
|
||||
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
|
||||
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
|
||||
}
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
|
||||
public static byte[] readTritonOutput(int slot, long lastSequence) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return null;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
|
||||
nativeStoreFence();
|
||||
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0 || seq <= lastSequence) return null;
|
||||
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
|
||||
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
|
||||
byte[] output = new byte[9 + length];
|
||||
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
|
||||
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
|
||||
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
|
||||
nativeStoreFence();
|
||||
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam client's newest unanswered feature read, or 0: its request number. */
|
||||
public static long readTritonFeatureRequest(int slot) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return 0L;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
|
||||
nativeStoreFence();
|
||||
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
|
||||
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
|
||||
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
|
||||
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
|
||||
}
|
||||
}
|
||||
|
||||
private static RingSlot tritonSlot(int slot) {
|
||||
synchronized (RING_LOCK) {
|
||||
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
object InputSpace {
|
||||
const val WIDTH = 1920
|
||||
const val HEIGHT = 1080
|
||||
|
||||
fun x(position: Float, viewWidth: Int): Int = scale(position, viewWidth, WIDTH)
|
||||
|
||||
fun y(position: Float, viewHeight: Int): Int = scale(position, viewHeight, HEIGHT)
|
||||
|
||||
private fun scale(position: Float, extent: Int, size: Int): Int =
|
||||
if (extent <= 0) 0 else (position / extent * size).toInt().coerceIn(0, size - 1)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.os.Build
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
@@ -95,8 +96,11 @@ class OnScreenControls(
|
||||
private var quickPressedBy = -1
|
||||
private var keyboardPressedBy = -1
|
||||
private var settings = ControllerPrefs.read(context)
|
||||
private var snap = settings.snap
|
||||
private var safe = Rect()
|
||||
private var selected: String? = null
|
||||
private var snapX: Float? = null
|
||||
private var snapY: Float? = null
|
||||
private var grabX = 0f
|
||||
private var grabY = 0f
|
||||
private var ignoreSaved = false
|
||||
@@ -138,10 +142,18 @@ class OnScreenControls(
|
||||
fun reload() {
|
||||
releaseAll()
|
||||
settings = ControllerPrefs.read(context)
|
||||
setSnap(settings.snap)
|
||||
applySettings()
|
||||
relayout()
|
||||
}
|
||||
|
||||
private fun clearSnapGuides() {
|
||||
if (snapX == null && snapY == null) return
|
||||
snapX = null
|
||||
snapY = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun applySettings() {
|
||||
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
|
||||
val tint = settings.tint
|
||||
@@ -179,6 +191,11 @@ class OnScreenControls(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setSnap(on: Boolean) {
|
||||
snap = on
|
||||
if (!on) clearSnapGuides()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
relayout()
|
||||
@@ -412,15 +429,18 @@ class OnScreenControls(
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
val group = selected ?: return
|
||||
val (x, y) = centre(group)
|
||||
stroke.color = heldStroke
|
||||
val single = members(group).singleOrNull()
|
||||
if (single?.wide == true) {
|
||||
val pad = dp(6f)
|
||||
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
|
||||
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
|
||||
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
|
||||
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
|
||||
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
|
||||
selected?.let { group ->
|
||||
val (x, y) = centre(group)
|
||||
val single = members(group).singleOrNull()
|
||||
if (single?.wide == true) {
|
||||
val pad = dp(6f)
|
||||
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
|
||||
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
|
||||
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
|
||||
}
|
||||
} else if (!buttonsOnly) {
|
||||
val (x, y) = quickCenter()
|
||||
val radius = dp(18f)
|
||||
@@ -587,6 +607,7 @@ class OnScreenControls(
|
||||
private fun onEditTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
clearSnapGuides()
|
||||
val control = controlAt(event.x, event.y)
|
||||
selected = control?.group
|
||||
val group = selected ?: run { invalidate(); return false }
|
||||
@@ -598,14 +619,29 @@ class OnScreenControls(
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val group = selected ?: return true
|
||||
ignoreSaved = false
|
||||
put(group, event.x + grabX, event.y + grabY)
|
||||
val rawX = event.x + grabX
|
||||
val rawY = event.y + grabY
|
||||
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
|
||||
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
|
||||
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
|
||||
put(group, nextX ?: rawX, nextY ?: rawY)
|
||||
clamp(group)
|
||||
val (x, y) = centre(group)
|
||||
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
|
||||
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
|
||||
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
snapX = visibleX
|
||||
snapY = visibleY
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun closestSnap(position: Float, targets: List<Float>): Float? =
|
||||
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
|
||||
|
||||
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)
|
||||
@@ -715,6 +751,8 @@ class OnScreenControls(
|
||||
const val PHONE_MAX_HEIGHT_MM = 90f
|
||||
const val SHOULDER_WIDTH = 1.45f
|
||||
const val SHOULDER_HEIGHT = 0.8f
|
||||
const val SNAP_DP = 9f
|
||||
const val SNAP_TOLERANCE_PX = 1f
|
||||
val shoulderIds = setOf("lb", "rb", "lt", "rt")
|
||||
val directions = listOf("up", "right", "down", "left")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.view.MotionEvent;
|
||||
import com.droiddeck.launcher.session.SessionState;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
|
||||
/**
|
||||
* A physical controller, republished as a synthetic evdev device the Steam client can see.
|
||||
@@ -25,12 +27,29 @@ import java.io.File;
|
||||
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
|
||||
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*
|
||||
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
|
||||
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
|
||||
* it, and played as the handheld while its built-in pad became player 2.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
|
||||
private static final int SLOT = 0;
|
||||
/** Player slots: the session prepares a ring for each (SessionService). */
|
||||
public static final int SLOTS = 4;
|
||||
private static final float DEAD_ZONE = 0.12f;
|
||||
private static final int[] JOYSTICK_AXES = {
|
||||
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
|
||||
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
|
||||
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
|
||||
};
|
||||
private static final int[] CONTROLLER_BUTTONS = {
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_X,
|
||||
KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1,
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START,
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
|
||||
};
|
||||
// The client takes A as part of the chord only once it has had Guide held for a while, and a
|
||||
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
|
||||
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
|
||||
@@ -50,11 +69,17 @@ public final class PadBridge {
|
||||
chordHandler = new Handler(thread.getLooper());
|
||||
}
|
||||
|
||||
private final File fakeInputDir;
|
||||
private final FakeInputWriter writer;
|
||||
private final PadState state = new PadState();
|
||||
/** The input device id holding each slot, or {@link #NO_CONTROLLER}. */
|
||||
private final int[] owners = new int[SLOTS];
|
||||
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
|
||||
private final PadState[] playerStates = new PadState[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
private boolean systemGuidePressed;
|
||||
private boolean systemQamPressed;
|
||||
private boolean qamChordActive;
|
||||
@@ -78,30 +103,67 @@ public final class PadBridge {
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
|
||||
// AYN's handhelds (the Thor) present their own pad as a USB device Android calls external,
|
||||
// beside the 2020:0111 it re-presents each Bluetooth Xbox pad as.
|
||||
private static final int AYN_VENDOR = 0x2020;
|
||||
private static final int AYN_BUILT_IN_PAD = 0x0112;
|
||||
|
||||
/** Whether this controller is the device's own controls rather than a pad connected to it. */
|
||||
static boolean isBuiltIn(InputDevice device) {
|
||||
return !device.isExternal()
|
||||
|| (device.getVendorId() == AYN_VENDOR && device.getProductId() == AYN_BUILT_IN_PAD);
|
||||
}
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove the pad, or
|
||||
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
|
||||
* on that slot finds the motors in the player's hands (RumbleComponent).
|
||||
* The Android input device id of the physical controller that last drove any slot, or
|
||||
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
|
||||
*/
|
||||
private static volatile int activeControllerId = NO_CONTROLLER;
|
||||
/** Per slot, the controller its rumble belongs to; slot 0's is none while the on-screen controls lead. */
|
||||
private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS);
|
||||
static {
|
||||
for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER);
|
||||
}
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
/** The controller playing in this ring slot, or the last one used for any other slot. */
|
||||
public static int controllerForSlot(int slot) {
|
||||
return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId;
|
||||
}
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
this.fakeInputDir = fakeInputDir;
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0);
|
||||
Arrays.fill(owners, NO_CONTROLLER);
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
/** The physical Select button opens the Steam menu instead of View/Select (Setup → Controller). */
|
||||
public void setSelectSteam(boolean on) {
|
||||
selectSteam = on;
|
||||
}
|
||||
|
||||
/** Opens the ring; safe to call more than once. */
|
||||
public synchronized boolean start() {
|
||||
if (!open) {
|
||||
open = writer.open();
|
||||
Log.i(TAG, "ring slot " + SLOT + (open ? " open" : " NOT open"));
|
||||
Log.i(TAG, "ring slot 0" + (open ? " open" : " NOT open"));
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (playerWriters[slot] != null) {
|
||||
playerWriters[slot].destroy();
|
||||
playerWriters[slot] = null;
|
||||
}
|
||||
if (playerStates[slot] != null) playerStates[slot].clear();
|
||||
}
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -123,8 +185,81 @@ public final class PadBridge {
|
||||
// counting it would hide the on-screen controls with nothing to replace them.
|
||||
if (device.isVirtual()) return false;
|
||||
int sources = device.getSources();
|
||||
return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|
||||
|| (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
|
||||
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
|
||||
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
|
||||
if (device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC) return true;
|
||||
// Some composite keyboards advertise joystick input without any sticks or pad buttons.
|
||||
// A keyboard's mouse ranges do not establish a joystick; a real hybrid's joystick ranges
|
||||
// or physical controller buttons do. GAMEPAD above remains authoritative even with keys.
|
||||
for (int axis : JOYSTICK_AXES) {
|
||||
InputDevice.MotionRange range = device.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
|
||||
if (range != null && range.getMax() > range.getMin()) return true;
|
||||
}
|
||||
for (boolean present : device.hasKeys(CONTROLLER_BUTTONS)) {
|
||||
if (present) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether this key belongs to the pad, rather than a keyboard on the same input device. */
|
||||
public static boolean isControllerKeyEvent(KeyEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
if (device == null || device.isVirtual()) return false;
|
||||
// An explicit pad button is unambiguous even when a hybrid omits its capabilities.
|
||||
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
|
||||
}
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_MENU:
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
if (!isFromController(device)) return false;
|
||||
// Without GAMEPAD, an alphabetic hybrid's keyboard arrows remain keyboard keys.
|
||||
// Its ambiguous navigation keys need explicit pad/joystick event provenance;
|
||||
// DPAD alone also describes ordinary keyboard arrows.
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|
||||
|| event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| event.isFromSource(InputDevice.SOURCE_JOYSTICK);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse motion on a composite controller still belongs to the pointer, not its sticks. */
|
||||
public static boolean isControllerMotionEvent(MotionEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
// An actual joystick event, like a pad button, survives a hybrid's missing capabilities.
|
||||
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
|
||||
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
|
||||
}
|
||||
|
||||
/** A Bluetooth keyboard can share a device with a joystick. Classify the key, not just
|
||||
* the device, so its letters and modifiers still reach the desktop keyboard. */
|
||||
public static boolean isControllerKey(KeyEvent event) {
|
||||
return isFromController(event.getDevice())
|
||||
&& isPadKey(event.getKeyCode(), event.getDevice().getKeyboardType());
|
||||
}
|
||||
|
||||
public static boolean isPadKey(int keyCode, int keyboardType) {
|
||||
if ((keyCode >= KeyEvent.KEYCODE_BUTTON_A && keyCode <= KeyEvent.KEYCODE_BUTTON_MODE)
|
||||
|| (keyCode >= KeyEvent.KEYCODE_BUTTON_1 && keyCode <= KeyEvent.KEYCODE_BUTTON_16)) return true;
|
||||
if (keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) return false;
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
case KeyEvent.KEYCODE_MENU: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any real controller is attached right now. */
|
||||
@@ -155,72 +290,81 @@ public final class PadBridge {
|
||||
|
||||
/** @return true when the event was a pad button and has been consumed. */
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
noteDevice(event.getDevice());
|
||||
if (!isControllerKeyEvent(event)) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: state.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: state.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: state.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: state.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: state.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: pad.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: pad.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: pad.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
case KeyEvent.KEYCODE_BACK: state.press(6, pressed); break;
|
||||
// Optionally (Setup → Controller) the physical Select button opens the Steam menu
|
||||
// instead of View/Select: on some handhelds it sits next to the Steam button.
|
||||
if (selectSteam) pad.press(PadState.GUIDE, pressed);
|
||||
else pad.press(6, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: state.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: state.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: state.press(9, pressed); break;
|
||||
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: pad.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
|
||||
// The client's own in-game menu is opened by this one; the interposer publishes it as
|
||||
// BTN_MODE, which SDL reports as the "guide" button.
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: state.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: state.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: state.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: state.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: state.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: state.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break;
|
||||
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
|
||||
default: return false;
|
||||
}
|
||||
noteDevice(event.getDevice(), slot);
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
publish(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return true when the event was a pad's sticks/triggers and has been consumed. */
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (!isControllerMotionEvent(event)) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
noteDevice(event.getDevice());
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
statAxes++;
|
||||
scheduleStats();
|
||||
state.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
|
||||
// pad reports as "down" is what the client is told.
|
||||
state.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
state.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
state.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
pad.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
// Some pads only report the triggers on BRAKE/GAS.
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
state.leftTrigger = lt;
|
||||
state.rightTrigger = rt;
|
||||
pad.leftTrigger = lt;
|
||||
pad.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
|
||||
state.up = hatY < -0.5f;
|
||||
state.right = hatX > 0.5f;
|
||||
state.down = hatY > 0.5f;
|
||||
state.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) {
|
||||
pad.up = hatY < -0.5f;
|
||||
pad.right = hatX > 0.5f;
|
||||
pad.down = hatY > 0.5f;
|
||||
pad.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
|
||||
notePlayerInput();
|
||||
}
|
||||
publish();
|
||||
publish(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -231,12 +375,30 @@ public final class PadBridge {
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* A controller the app reads itself rather than through Android's input (the Steam Controller
|
||||
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
|
||||
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
|
||||
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
|
||||
*/
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
mutation.accept(state);
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
|
||||
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
|
||||
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
|
||||
@@ -255,6 +417,31 @@ public final class PadBridge {
|
||||
public synchronized void releaseAll() {
|
||||
state.clear();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (playerWriters[slot] == null) continue;
|
||||
playerStates[slot].clear();
|
||||
publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every connected controller its slot now, so the client lists each one as it is
|
||||
* connected instead of on its first press: a further player's pad is plugged in, at rest.
|
||||
*/
|
||||
public synchronized void claimConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (!isFromController(device)) continue;
|
||||
int slot = slotFor(device);
|
||||
if (slot > 0 && playerWriters[slot] == null) publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
|
||||
public synchronized void onDeviceRemoved(int deviceId) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == deviceId) free(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
|
||||
@@ -310,16 +497,88 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
|
||||
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
|
||||
* of its own), else player 1's.
|
||||
*/
|
||||
private int slotFor(InputDevice device) {
|
||||
int id = device.getId();
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == id) return slot;
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
|
||||
}
|
||||
if (isBuiltIn(device)) {
|
||||
// A connected pad playing as the device's own controls moves to a player's slot of its own.
|
||||
int was = owners[0];
|
||||
if (was != NO_CONTROLLER) {
|
||||
free(0);
|
||||
InputDevice other = InputDevice.getDevice(was);
|
||||
if (other != null && !isBuiltIn(other)) {
|
||||
int to = claim(other, 1);
|
||||
if (to > 0) publish(to);
|
||||
}
|
||||
}
|
||||
owners[0] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
|
||||
return 0;
|
||||
}
|
||||
int slot = claim(device, builtInConnected() ? 1 : 0);
|
||||
return slot >= 0 ? slot : 0;
|
||||
}
|
||||
|
||||
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
|
||||
private int claim(InputDevice device, int from) {
|
||||
for (int slot = from; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER) continue;
|
||||
owners[slot] = device.getId();
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
|
||||
return slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean builtInConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (isFromController(device) && isBuiltIn(device)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PadState stateFor(int slot) {
|
||||
return slot == 0 ? state : playerStates[slot];
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (slot == 0) {
|
||||
state.clear();
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
playerStates[slot].clear();
|
||||
if (playerWriters[slot] != null) {
|
||||
playerWriters[slot].destroy();
|
||||
playerWriters[slot] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
private void noteDevice(InputDevice device, int slot) {
|
||||
activeControllerId = device.getId();
|
||||
slotControllers.set(slot, device.getId());
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
if (seenDevices.add(device.getId())) {
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); slot %d, ring %s, deck pad %b",
|
||||
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
|
||||
open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
slot, open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +598,23 @@ public final class PadBridge {
|
||||
statButtons = statAxes = statOnScreen = statDropped = 0;
|
||||
}
|
||||
|
||||
private void publish(int slot) {
|
||||
if (slot == 0) {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
FakeInputWriter player = playerWriters[slot];
|
||||
if (player == null) {
|
||||
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
|
||||
if (!player.open()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
player.writePad(playerStates[slot]);
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
|
||||
@@ -20,9 +20,10 @@ import kotlin.math.roundToInt
|
||||
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
|
||||
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
|
||||
*
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
|
||||
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
|
||||
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, and the client
|
||||
* wants motion (see [follow]), sampled as often as the Deck reports (4 ms); each reading goes
|
||||
* straight into the pad's ring ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report
|
||||
* picks up the latest.
|
||||
*/
|
||||
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(SensorManager::class.java)
|
||||
@@ -32,6 +33,11 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
// Touched only on the worker thread.
|
||||
private val accel = ShortArray(3)
|
||||
private val gyro = ShortArray(3)
|
||||
private var reading = false
|
||||
private var idlePolls = 0
|
||||
// Asking the display costs more than the rest of a reading, which arrives every 2 ms.
|
||||
private var turn = Surface.ROTATION_0
|
||||
private var turnAt = 0L
|
||||
|
||||
fun start() {
|
||||
if (thread != null || sensors == null) return
|
||||
@@ -45,8 +51,13 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
thread = worker
|
||||
val handler = Handler(worker.looper)
|
||||
active = true
|
||||
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (!active) return
|
||||
follow(handler, gyroscope, accelerometer)
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
})
|
||||
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
|
||||
}
|
||||
|
||||
@@ -54,30 +65,71 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
val worker = thread ?: return
|
||||
thread = null
|
||||
active = false
|
||||
sensors?.unregisterListener(this)
|
||||
// A still controller, not one frozen mid-turn - published on the worker, after any reading
|
||||
// already queued there, so nothing can overwrite it.
|
||||
// On the worker, after any poll or reading already queued there, so nothing registers again
|
||||
// or overwrites the still controller.
|
||||
Handler(worker.looper).post {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
sensors?.unregisterListener(this)
|
||||
reading = false
|
||||
idlePolls = 0
|
||||
still()
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the sensors while the client wants motion: the Deck's IMU mode (unknown counts as
|
||||
* wanted), or Steam's touch controls asking for gyro. A game whose layout has no gyro turns the
|
||||
* mode off; Steam's own menus keep it on. The client also turns it off and on again for a few
|
||||
* ms whenever it re-applies a layout, so the sensors stop only after two polls without it.
|
||||
*/
|
||||
private fun follow(handler: Handler, gyroscope: Sensor?, accelerometer: Sensor?) {
|
||||
val touch = SteamTouchDevice.current
|
||||
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
|
||||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
|
||||
idlePolls = if (wanted) 0 else idlePolls + 1
|
||||
if (wanted && !reading) {
|
||||
reading = true
|
||||
turnAt = 0L
|
||||
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
|
||||
Log.i(TAG, "pad motion: reading")
|
||||
} else if (reading && idlePolls >= 2) {
|
||||
reading = false
|
||||
sensors?.unregisterListener(this)
|
||||
still()
|
||||
Log.i(TAG, "pad motion: paused, the client wants none")
|
||||
}
|
||||
}
|
||||
|
||||
/** A still controller, not one frozen mid-turn. */
|
||||
private fun still() {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (!active) return
|
||||
val values = event.values
|
||||
if (event.timestamp - turnAt > ROTATION_REFRESH_NS) {
|
||||
turn = rotation()
|
||||
turnAt = event.timestamp
|
||||
}
|
||||
// Natural orientation to the screen's: Android's display-rotation transform.
|
||||
val (x, y) = when (rotation()) {
|
||||
Surface.ROTATION_90 -> -values[1] to values[0]
|
||||
Surface.ROTATION_180 -> -values[0] to -values[1]
|
||||
Surface.ROTATION_270 -> values[1] to -values[0]
|
||||
else -> values[0] to values[1]
|
||||
val x: Float
|
||||
val y: Float
|
||||
when (turn) {
|
||||
Surface.ROTATION_90 -> { x = -values[1]; y = values[0] }
|
||||
Surface.ROTATION_180 -> { x = -values[0]; y = -values[1] }
|
||||
Surface.ROTATION_270 -> { x = values[1]; y = -values[0] }
|
||||
else -> { x = values[0]; y = values[1] }
|
||||
}
|
||||
val z = values[2]
|
||||
val (target, scale) = when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
|
||||
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
|
||||
val target: ShortArray
|
||||
val scale: Float
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> { target = gyro; scale = GYRO_COUNTS_PER_RAD_S }
|
||||
Sensor.TYPE_ACCELEROMETER -> { target = accel; scale = ACCEL_COUNTS_PER_M_S2 }
|
||||
else -> return
|
||||
}
|
||||
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
|
||||
@@ -85,6 +137,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
target[1] = counts(-z * scale)
|
||||
target[2] = counts(y * scale)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
@@ -97,6 +150,9 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
private const val SLOT = 0
|
||||
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
|
||||
private const val SAMPLING_US = 4000
|
||||
/** How often [follow] checks whether the client wants motion. */
|
||||
private const val POLL_MS = 500L
|
||||
private const val ROTATION_REFRESH_NS = 250_000_000L
|
||||
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
|
||||
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
|
||||
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
|
||||
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
|
||||
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
|
||||
*
|
||||
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
|
||||
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var running = false
|
||||
private var input: UUID? = null
|
||||
private var reportId = 0
|
||||
private var lizardOffSent = false
|
||||
private var reportedNone = false
|
||||
private var discoveries = 0
|
||||
private var outputSequence = 0L
|
||||
private var outputWritePending = false
|
||||
/** The Steam client's feature read being answered, so the poll queues each one once. */
|
||||
private var featureRead = 0L
|
||||
|
||||
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
|
||||
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
|
||||
fun stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running || gatt != null) return
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrNull()
|
||||
val device = bonded?.firstOrNull(::isSteamController)
|
||||
if (device == null) {
|
||||
if (!reportedNone) {
|
||||
reportedNone = true
|
||||
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
|
||||
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
|
||||
}
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "connecting to \"${device.name}\"")
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
|
||||
}
|
||||
|
||||
private fun disconnect(why: String) {
|
||||
val g = gatt ?: return
|
||||
gatt = null
|
||||
discoveries = 0
|
||||
input = null
|
||||
lizardOffSent = false
|
||||
outputWritePending = false
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
g.disconnect()
|
||||
g.close()
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (g !== gatt) return
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
g.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
disconnect("link lost (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
disconnect("service discovery failed (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
// The Triton needs data length extensions, which Android enables for a large MTU.
|
||||
if (!g.requestMtu(517)) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (g === gatt) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
pollOutput()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (characteristic.uuid == REPORT && !lizardOffSent) {
|
||||
lizardOffSent = true
|
||||
Log.i(TAG, "lizard mode off (status $status)")
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
|
||||
}
|
||||
outputWritePending = false
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
|
||||
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
// Android 13 hands the value over with the call; older ones only through the characteristic.
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (g === gatt && characteristic.uuid == input) onState(value)
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt) {
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
|
||||
g.services.joinToString { it.uuid.toString().take(8) })
|
||||
// Android may answer from the service list its HID host cached, which can lack the
|
||||
// vendor service: ask the controller again before giving up on it.
|
||||
if (++discoveries < 3) {
|
||||
refreshCache(g)
|
||||
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
|
||||
} else {
|
||||
disconnect("no Valve service")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
|
||||
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
|
||||
if (chr == null) {
|
||||
disconnect("no state report characteristic")
|
||||
return
|
||||
}
|
||||
outputReports.clear()
|
||||
service.characteristics.forEach { characteristic ->
|
||||
val uuid = characteristic.uuid.toString().lowercase()
|
||||
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
|
||||
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
|
||||
val outputId = characteristicId - OUTPUT_UUID_OFFSET
|
||||
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
|
||||
input = chr.uuid
|
||||
g.setCharacteristicNotification(chr, true)
|
||||
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
|
||||
@Suppress("DEPRECATION")
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeDescriptor(cccd)
|
||||
}
|
||||
|
||||
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
|
||||
private fun refreshCache(g: BluetoothGatt) {
|
||||
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
val msg = ByteArray(FEATURE_BYTES)
|
||||
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
|
||||
enqueueOutput(REPORT, msg)
|
||||
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
|
||||
}
|
||||
|
||||
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
}
|
||||
|
||||
/** A neutral report prevents a link lost mid-press from leaving controls held. */
|
||||
private fun release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
|
||||
private fun pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
|
||||
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
|
||||
}
|
||||
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
|
||||
if (request != 0L && request != featureRead) {
|
||||
featureRead = request
|
||||
// Behind any queued write, so a read answers the feature report written before it.
|
||||
queuedWrites.addLast(REPORT to null)
|
||||
writeNextOutput()
|
||||
}
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
|
||||
}
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
|
||||
if (report.size < 2) return
|
||||
val characteristic = if (feature) {
|
||||
REPORT
|
||||
} else {
|
||||
val id = report[0].toInt() and 0xff
|
||||
outputReports[id] ?: run {
|
||||
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
|
||||
return
|
||||
}
|
||||
}
|
||||
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
|
||||
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
|
||||
}
|
||||
|
||||
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
|
||||
queuedWrites.addLast(characteristic to value)
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
outputWritePending = true
|
||||
} else {
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = value
|
||||
@Suppress("DEPRECATION")
|
||||
if (!g.writeCharacteristic(characteristic)) {
|
||||
queuedWrites.addFirst(uuid to value)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
} else {
|
||||
outputWritePending = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamControllerBle"
|
||||
/** The paired controller's native Triton reports live in slot 0's extended ring. */
|
||||
private const val SLOT = 0
|
||||
private const val RETRY_MS = 5_000L
|
||||
private const val LIZARD_REFRESH_MS = 3_000L
|
||||
private const val OUTPUT_POLL_MS = 4L
|
||||
private const val OUTPUT_RETRY_MS = 10L
|
||||
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
|
||||
private const val FEATURE_BYTES = 62
|
||||
private const val STATE_BYTES = 45
|
||||
private const val OUTPUT_UUID_PREFIX = "100f6c"
|
||||
private const val OUTPUT_UUID_OFFSET = 0x35
|
||||
|
||||
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
|
||||
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
|
||||
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private fun isSteamController(device: BluetoothDevice): Boolean =
|
||||
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
|
||||
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
|
||||
|
||||
/** Whether a session should expose a Triton HID node for a paired controller. */
|
||||
@JvmStatic
|
||||
fun hasPairedController(context: Context): Boolean {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
|
||||
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** A Steam Controller is connected and feeding its native HID reports. */
|
||||
@Volatile @JvmStatic
|
||||
var connected = false
|
||||
private set
|
||||
|
||||
/** Notifies the activity when [connected] changes. */
|
||||
@Volatile @JvmStatic
|
||||
var listener: (() -> Unit)? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What each touch control is bound to, and the icon it shows - Steam's, as its configurator keeps
|
||||
* them.
|
||||
*
|
||||
* A Steam Input binding is one string: `<action>, <label>, <icon>, <#foreground #background>`, e.g.
|
||||
* `xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4`. The icon is a file name looked
|
||||
* up as Steam does (steamui.so): the game's own `TouchMenuIcons` folder, then the binding icon
|
||||
* library `tenfoot/resource/images/library/controller/binding_icons`. Steam draws it filled with
|
||||
* the foreground colour through its own alpha (mask-image) on a button of the background colour.
|
||||
* Steam Link is sent these per control by the client it streams from; here they are read from the
|
||||
* game's touch config, and editing one writes the binding string back, as the configurator does.
|
||||
*/
|
||||
object SteamTouchBindings {
|
||||
|
||||
/** Steam's binding icon colours (the configurator's palette, steamui sp.js), and its defaults. */
|
||||
val PALETTE = listOf(
|
||||
"#FFFFFF", "#E4E4E4", "#AAAAAA", "#787878", "#434343", "#222222", "#0000AD", "#0045AD", "#0074AD",
|
||||
"#00ADAD", "#33AD69", "#00AD3D", "#00AD00", "#48B119", "#74AD00", "#96AD00", "#ADA200", "#AD5D00",
|
||||
"#AD3A00", "#AD0000", "#AD0051", "#AD007F", "#AD00AD", "#6800AD", "#4800AD",
|
||||
)
|
||||
const val DEFAULT_FOREGROUND = "#232323"
|
||||
const val DEFAULT_BACKGROUND = "#E4E4E4"
|
||||
|
||||
/** A parsed binding string. Empty strings for absent fields. */
|
||||
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
|
||||
fun compose(): String =
|
||||
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
|
||||
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
|
||||
|
||||
val hasIcon get() = icon.isNotEmpty()
|
||||
}
|
||||
|
||||
fun parse(binding: String): Binding {
|
||||
val parts = binding.split(',').map { it.trim() }
|
||||
val colors = parts.getOrNull(3).orEmpty().split(' ').filter { it.startsWith("#") }
|
||||
return Binding(parts.getOrNull(0).orEmpty(), parts.getOrNull(1).orEmpty(), parts.getOrNull(2).orEmpty(),
|
||||
colors.getOrNull(0).orEmpty(), colors.getOrNull(1).orEmpty())
|
||||
}
|
||||
|
||||
/** Where a control's binding lives: its input in the group the preset puts on its source. */
|
||||
data class Ref(val groupId: String, val input: String)
|
||||
|
||||
/** The source and input names a control reads (D-pad: its four directions). */
|
||||
private fun inputsOf(type: Int): List<Pair<String, String>> = when (type) {
|
||||
SteamTouchConfig.A -> listOf("button_diamond" to "button_a")
|
||||
SteamTouchConfig.B -> listOf("button_diamond" to "button_b")
|
||||
SteamTouchConfig.X -> listOf("button_diamond" to "button_x")
|
||||
SteamTouchConfig.Y -> listOf("button_diamond" to "button_y")
|
||||
SteamTouchConfig.SELECT -> listOf("switch" to "button_menu")
|
||||
SteamTouchConfig.START -> listOf("switch" to "button_escape")
|
||||
SteamTouchConfig.BUMPER_LEFT -> listOf("switch" to "left_bumper")
|
||||
SteamTouchConfig.BUMPER_RIGHT -> listOf("switch" to "right_bumper")
|
||||
SteamTouchConfig.TRIGGER_LEFT -> listOf("left_trigger" to "click")
|
||||
SteamTouchConfig.TRIGGER_RIGHT -> listOf("right_trigger" to "click")
|
||||
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> listOf("joystick" to "click")
|
||||
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> listOf("right_joystick" to "click")
|
||||
SteamTouchConfig.MACRO_1_FINGER -> listOf("switch" to "button_macro_1finger")
|
||||
SteamTouchConfig.MACRO_2_FINGER -> listOf("switch" to "button_macro_2finger")
|
||||
SteamTouchConfig.DPAD -> DPAD_INPUTS.map { "dpad" to it }
|
||||
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> listOf("switch" to "button_macro${type - SteamTouchConfig.MACRO_0}")
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/** D-pad directions, in the order [refsFor] returns them: up, down, left, right. */
|
||||
val DPAD_INPUTS = listOf("dpad_north", "dpad_south", "dpad_west", "dpad_east")
|
||||
|
||||
/**
|
||||
* Where control [type]'s bindings are for preset [preset] (the action set less one) and its
|
||||
* active [layers]: a layer's group wins over the base's for the same source.
|
||||
*/
|
||||
fun refsFor(mappings: KeyValues.Node, type: Int, preset: Int, layers: List<Int> = emptyList()): List<Ref?> {
|
||||
val presets = mappings.children("preset").associateBy { it.string("id")?.toIntOrNull() ?: -1 }
|
||||
val stack = layers.reversed().mapNotNull { presets[it - 1] } + listOfNotNull(presets[preset] ?: presets.values.firstOrNull())
|
||||
return inputsOf(type).map { (source, input) ->
|
||||
stack.firstNotNullOfOrNull { p -> groupFor(p, source)?.let { Ref(it, input) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupFor(preset: KeyValues.Node, source: String): String? =
|
||||
preset.child("group_source_bindings")?.entries?.firstOrNull { (_, v) ->
|
||||
val parts = (v as? String)?.split(' ') ?: return@firstOrNull false
|
||||
parts.getOrNull(0) == source && parts.getOrNull(1) == "active" && !parts.contains("modeshift")
|
||||
}?.first
|
||||
|
||||
/** The binding at [ref]: its first activator's first binding (Full_Press when there is one). */
|
||||
fun bindingAt(mappings: KeyValues.Node, ref: Ref): Binding? {
|
||||
val group = mappings.children("group").firstOrNull { it.string("id") == ref.groupId } ?: return null
|
||||
val activators = group.child("inputs")?.child(ref.input)?.child("activators") ?: return null
|
||||
val activator = activators.child("Full_Press") ?: activators.entries.firstNotNullOfOrNull { it.second as? KeyValues.Node }
|
||||
return activator?.child("bindings")?.string("binding")?.let(::parse)
|
||||
}
|
||||
|
||||
/** A short glyph for a binding with no icon or label: what it presses. */
|
||||
fun glyph(binding: Binding?): String? {
|
||||
val words = binding?.action?.split(' ') ?: return null
|
||||
val arg = words.getOrNull(1)?.uppercase() ?: return null
|
||||
return when (words[0]) {
|
||||
"xinput_button" -> XINPUT_GLYPHS[arg] ?: arg
|
||||
"key_press" -> KEY_GLYPHS[arg] ?: arg.take(4)
|
||||
"mouse_button" -> when (arg) { "LEFT" -> "LMB"; "RIGHT" -> "RMB"; "MIDDLE" -> "MMB"; else -> "M" + arg.take(2) }
|
||||
"mouse_wheel" -> if (arg.contains("UP")) "▲" else "▼"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val XINPUT_GLYPHS = mapOf(
|
||||
"SHOULDER_LEFT" to "LB", "SHOULDER_RIGHT" to "RB", "TRIGGER_LEFT" to "LT", "TRIGGER_RIGHT" to "RT",
|
||||
"JOYSTICK_LEFT" to "L3", "JOYSTICK_RIGHT" to "R3", "START" to "☰", "SELECT" to "⧉", "BACK" to "⧉",
|
||||
"DPAD_UP" to "▲", "DPAD_DOWN" to "▼", "DPAD_LEFT" to "◀", "DPAD_RIGHT" to "▶",
|
||||
)
|
||||
private val KEY_GLYPHS = mapOf(
|
||||
"RETURN" to "⏎", "SPACE" to "␣", "ESCAPE" to "Esc", "TAB" to "Tab", "BACKSPACE" to "⌫",
|
||||
"LEFT_SHIFT" to "⇧", "RIGHT_SHIFT" to "⇧", "LEFT_CONTROL" to "Ctrl", "RIGHT_CONTROL" to "Ctrl",
|
||||
"LEFT_ALT" to "Alt", "RIGHT_ALT" to "Alt", "UP_ARROW" to "↑", "DOWN_ARROW" to "↓",
|
||||
"LEFT_ARROW" to "←", "RIGHT_ARROW" to "→",
|
||||
)
|
||||
|
||||
// ---- Writing a binding back ----
|
||||
|
||||
/**
|
||||
* Rewrites every binding of [ref]'s input in config [text] (VDF) with [change], keeping the rest
|
||||
* of the file byte for byte. Returns the new text, or null when the input is not there.
|
||||
*/
|
||||
fun rewrite(text: String, ref: Ref, change: (Binding) -> Binding): String? {
|
||||
val tokens = Vdf.tokens(text)
|
||||
val group = Vdf.blocks(tokens, 0, tokens.size, "group").firstOrNull { (s, e) ->
|
||||
Vdf.value(tokens, s, e, "id") == ref.groupId
|
||||
} ?: return null
|
||||
val inputs = Vdf.blocks(tokens, group.first, group.second, "inputs").firstOrNull() ?: return null
|
||||
val input = Vdf.blocks(tokens, inputs.first, inputs.second, ref.input).firstOrNull() ?: return null
|
||||
val targets = (input.first until input.second).filter { i ->
|
||||
tokens[i].text.equals("binding", true) && tokens[i].quoted && i + 1 < input.second && tokens[i + 1].quoted
|
||||
}.map { tokens[it + 1] }
|
||||
if (targets.isEmpty()) return null
|
||||
val out = StringBuilder(text)
|
||||
for (t in targets.sortedByDescending { it.start }) out.replace(t.start, t.end, change(parse(t.text)).compose())
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** A VDF tokenizer that keeps where each token sits, for edits that leave the rest untouched. */
|
||||
private object Vdf {
|
||||
class Token(val text: String, val quoted: Boolean, val start: Int, val end: Int)
|
||||
|
||||
fun tokens(text: String): List<Token> {
|
||||
val out = mutableListOf<Token>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += Token(c.toString(), false, i, i + 1); i++ }
|
||||
c == '"' -> {
|
||||
val start = i + 1
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') { if (text[i] == '\\') i++; i++ }
|
||||
out += Token(text.substring(start, minOf(i, text.length)), true, start, minOf(i, text.length))
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += Token(text.substring(start, i), false, start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The bodies (token index ranges inside the braces) of direct children named [key] of the
|
||||
* block spanning [from, to). */
|
||||
fun blocks(tokens: List<Token>, from: Int, to: Int, key: String): List<Pair<Int, Int>> {
|
||||
val found = mutableListOf<Pair<Int, Int>>()
|
||||
var i = from
|
||||
var depth = 0
|
||||
while (i < to) {
|
||||
val t = tokens[i]
|
||||
if (t.text == "{" && !t.quoted) depth++
|
||||
else if (t.text == "}" && !t.quoted) depth--
|
||||
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].text == "{" && !tokens[i + 1].quoted) {
|
||||
val open = i + 1
|
||||
var d = 0
|
||||
var j = open
|
||||
while (j < to) {
|
||||
if (tokens[j].text == "{" && !tokens[j].quoted) d++
|
||||
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
|
||||
j++
|
||||
}
|
||||
found += (open + 1) to j
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
// The whole file is one top-level block ("controller_mappings"): look inside it too.
|
||||
if (found.isEmpty() && from == 0) {
|
||||
val top = tokens.indexOfFirst { it.text == "{" && !it.quoted }
|
||||
if (top > 0) {
|
||||
var d = 0
|
||||
var j = top
|
||||
while (j < to) {
|
||||
if (tokens[j].text == "{" && !tokens[j].quoted) d++
|
||||
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
|
||||
j++
|
||||
}
|
||||
return blocks(tokens, top + 1, j, key)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** A direct key-value of the block spanning [from, to). */
|
||||
fun value(tokens: List<Token>, from: Int, to: Int, key: String): String? {
|
||||
var depth = 0
|
||||
var i = from
|
||||
while (i < to) {
|
||||
val t = tokens[i]
|
||||
if (t.text == "{" && !t.quoted) depth++
|
||||
else if (t.text == "}" && !t.quoted) depth--
|
||||
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].quoted) return tokens[i + 1].text
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Icons ----
|
||||
|
||||
private val cache = object : LruCache<String, Bitmap>(24 * 1024 * 1024) {
|
||||
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
|
||||
}
|
||||
private val missing = HashSet<String>()
|
||||
|
||||
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
|
||||
|
||||
/** The game's own icons folder (`TouchMenuIcons` in its install directory), when it has one. */
|
||||
fun gameIconsDir(context: Context, appId: Int): File? = try {
|
||||
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
|
||||
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
|
||||
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun iconFile(context: Context, appId: Int, name: String): File? {
|
||||
if (name.isEmpty() || name.contains('/')) return null
|
||||
gameIconsDir(context, appId)?.let { File(it, name) }?.takeIf { it.isFile }?.let { return it }
|
||||
File(libraryDir(context), name).takeIf { it.isFile }?.let { return it }
|
||||
return File(SteamTouchConfig.steamRoot(context), "steamui/images/controller/$name").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
/** An icon, decoded at about [size] px. Blocking (reads a file); cached. */
|
||||
fun icon(context: Context, appId: Int, name: String, size: Int = 128): Bitmap? {
|
||||
val key = "$appId/$name/$size"
|
||||
cache.get(key)?.let { return it }
|
||||
if (key in missing) return null
|
||||
val file = iconFile(context, appId, name)
|
||||
val bitmap = file?.let {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(it.path, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= size) sample *= 2
|
||||
BitmapFactory.decodeFile(it.path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
}
|
||||
if (bitmap == null) missing += key else cache.put(key, bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun cachedIcon(appId: Int, name: String, size: Int = 128): Bitmap? = cache.get("$appId/$name/$size")
|
||||
|
||||
/** Icon names for the picker: the game's own first, then Steam's library by category. */
|
||||
fun iconNames(context: Context, appId: Int): List<String> {
|
||||
val own = gameIconsDir(context, appId)?.list()?.filter { it.endsWith(".png", true) }?.sorted().orEmpty()
|
||||
val library = libraryDir(context).list()?.filter { it.endsWith(".png", true) && it != "special_blank.png" }?.sorted().orEmpty()
|
||||
return own + library.filter { it !in own }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Steam's touch configs, read and written where Steam keeps them.
|
||||
*
|
||||
* A touch config is a Steam Input config (`controller_mobile_touch`) like any other - bindings per
|
||||
* action set, synced to the account, shared as community configs - with one extra key,
|
||||
* `touch_layout`: where each on-screen control sits, per action set, as a hex-encoded
|
||||
* `CVirtualControllerLayouts` protobuf (steammessages_virtualcontroller.proto). Steam Link is
|
||||
* handed both by the client it streams from; here the client tells the device only the app and
|
||||
* action set it is on (SteamTouchDevice), so the config is found on disk: the file the client's
|
||||
* console log says it loaded for the touch controller, else the app's entry in
|
||||
* `configset_controller_mobile_touch.vdf`. With no layout in it, controls go where Steam Link puts
|
||||
* them by default. Saving a layout writes `touch_layout` into the app's autosave config, as the
|
||||
* client does when Steam Link saves one; Steam Cloud picks that file up like any autosave.
|
||||
* (docs/development/steam-touch-controller.md.)
|
||||
*/
|
||||
object SteamTouchConfig {
|
||||
private const val TAG = "SteamTouch"
|
||||
|
||||
// EControllerElementType.
|
||||
const val THUMB = 0
|
||||
const val STEAM = 1
|
||||
const val JOYSTICK_LEFT = 2
|
||||
const val JOYSTICK_LEFT_BUTTON = 3
|
||||
const val JOYSTICK_RIGHT = 4
|
||||
const val JOYSTICK_RIGHT_BUTTON = 5
|
||||
const val DPAD = 6
|
||||
const val A = 7
|
||||
const val B = 8
|
||||
const val X = 9
|
||||
const val Y = 10
|
||||
const val SELECT = 11
|
||||
const val START = 12
|
||||
const val TRIGGER_LEFT = 13
|
||||
const val TRIGGER_RIGHT = 14
|
||||
const val BUMPER_LEFT = 15
|
||||
const val BUMPER_RIGHT = 16
|
||||
const val MACRO_0 = 17 // 17..24 = macros 0-7
|
||||
const val TRACKPAD_CENTER = 25
|
||||
const val TRACKPAD_LEFT = 26
|
||||
const val TRACKPAD_RIGHT = 27
|
||||
const val KEYBOARD = 28
|
||||
const val MACRO_1_FINGER = 30
|
||||
const val MACRO_2_FINGER = 31
|
||||
const val MAGNIFYING_GLASS = 29
|
||||
const val PASTE = 34
|
||||
|
||||
/** Controls a layout may place though no binding asks for them (Steam Link's tray). */
|
||||
val OPTIONAL = setOf(PASTE)
|
||||
|
||||
/** A control's place: centre as a fraction of the screen, and its scale. */
|
||||
data class Element(val type: Int, val visible: Boolean, val x: Float, val y: Float, val xScale: Float = 1f, val yScale: Float = 1f)
|
||||
|
||||
data class Layout(val actionSet: Int, val elements: List<Element>, val color: FloatArray?, val version: Int?)
|
||||
|
||||
/** A decoded `touch_layout`; [rest] is every other top-level field, written back untouched. */
|
||||
class Layouts(val layouts: List<Layout>, val rest: ByteArray) {
|
||||
fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id }
|
||||
|
||||
/** The per-game options Steam Link keeps beside the layouts (CVirtualControllerLayouts
|
||||
* fields 2-4): input mode, mouse mode, trackpad sensitivity. */
|
||||
val options: Options get() = Options.decode(rest)
|
||||
}
|
||||
|
||||
/** EInputMode: 1 mouse, 2 controller, 3 both. EMouseMode: 2 absolute cursor (direct touch),
|
||||
* 3 touch, 4 relative (trackpad); 0 = not set. */
|
||||
data class Options(val inputMode: Int = INPUT_CONTROLLER, val mouseMode: Int = 0, val trackpadSensitivity: Float = 1f) {
|
||||
/** [rest] with these options in place of its own fields 2-4, the rest kept as it was. */
|
||||
fun encodeInto(rest: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val p = Proto(rest)
|
||||
while (p.more()) {
|
||||
val start = p.pos
|
||||
val (field, wire) = p.key()
|
||||
p.skip(wire)
|
||||
if (field !in 2..4) out.write(rest, start, p.pos - start)
|
||||
}
|
||||
tag(out, 2, 0); varint(out, inputMode.toLong())
|
||||
if (mouseMode != 0) { tag(out, 3, 0); varint(out, mouseMode.toLong()) }
|
||||
tag(out, 4, 5); fixed(out, trackpadSensitivity)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(rest: ByteArray): Options {
|
||||
var input = INPUT_CONTROLLER
|
||||
var mouse = 0
|
||||
var sensitivity = 1f
|
||||
val p = Proto(rest)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 2 && wire == 0 -> input = p.varint().toInt()
|
||||
field == 3 && wire == 0 -> mouse = p.varint().toInt()
|
||||
field == 4 && wire == 5 -> sensitivity = p.float()
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Options(input, mouse, sensitivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val INPUT_MOUSE = 1
|
||||
const val INPUT_CONTROLLER = 2
|
||||
const val INPUT_BOTH = 3
|
||||
const val MOUSE_ABSOLUTE = 2
|
||||
const val MOUSE_TOUCH = 3
|
||||
const val MOUSE_RELATIVE = 4
|
||||
|
||||
/** The config's action sets in preset order: id as the device is told (preset id + 1) and title. */
|
||||
fun actionSets(config: Config): List<Pair<Int, String>> {
|
||||
val mappings = config.mappings ?: return listOf(1 to "Default")
|
||||
val titles = mappings.child("actions")?.entries?.mapNotNull { (key, v) ->
|
||||
(v as? KeyValues.Node)?.let { key to (it.string("title") ?: key) }
|
||||
}?.toMap().orEmpty()
|
||||
val sets = mappings.children("preset").mapNotNull { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: return@mapNotNull null
|
||||
val name = preset.string("name") ?: ""
|
||||
(id + 1) to (titles[name]?.takeUnless { it.startsWith("#") } ?: name.ifEmpty { "Set ${id + 1}" })
|
||||
}.sortedBy { it.first }
|
||||
return sets.ifEmpty { listOf(1 to "Default") }
|
||||
}
|
||||
|
||||
/** A config as the overlay needs it. */
|
||||
class Config(
|
||||
val file: File?,
|
||||
val text: String?,
|
||||
val layouts: Layouts?,
|
||||
/** Bound controls per preset (action set id - 1). */
|
||||
val available: Map<Int, Set<Int>>,
|
||||
/** The parsed config, for its bindings (SteamTouchBindings). */
|
||||
val mappings: KeyValues.Node? = null,
|
||||
) {
|
||||
/** The config's own title, as Steam Link's tray shows it ("Default", "Gamepad"…). */
|
||||
val title: String? get() = mappings?.string("title")?.let { t ->
|
||||
if (t.startsWith("#")) mappings.child("localization")?.child("english")?.string("title") ?: "Default" else t
|
||||
}
|
||||
|
||||
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
|
||||
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
|
||||
val base = available[presetOf(actionSet)] ?: available.values.firstOrNull() ?: DEFAULT_SET
|
||||
return layers.fold(base) { set, layer -> set + (available[presetOf(layer)] ?: emptySet()) } + ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
/** Big Picture's own config uses Steam's basic UI bindings: the default set. */
|
||||
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
|
||||
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
|
||||
|
||||
/** The layout entry for the action set the device was told about (the client counts from 1,
|
||||
* 0 for an app with none). */
|
||||
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
|
||||
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
|
||||
|
||||
// ---- Where Steam keeps them ----
|
||||
|
||||
/** A game's name from its app manifest, when it is installed. */
|
||||
fun gameName(context: Context, appId: Int): String? = try {
|
||||
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
|
||||
(kv.child("AppState") ?: kv).string("name")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
|
||||
fun configDir(context: Context): File? {
|
||||
val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs")
|
||||
val account = accountId(context)
|
||||
if (account != null) File(base, "$account/config").takeIf { it.isDirectory }?.let { return it }
|
||||
return base.listFiles()?.filter { it.name.all(Char::isDigit) && File(it, "config").isDirectory }
|
||||
?.maxByOrNull { it.lastModified() }?.let { File(it, "config") }
|
||||
}
|
||||
|
||||
/** The signed-in account (loginusers.vdf's MostRecent) as a 32-bit account id. */
|
||||
private fun accountId(context: Context): Long? = try {
|
||||
val users = KeyValues.parse(File(steamRoot(context), "config/loginusers.vdf").readText())
|
||||
val node = users.child("users")
|
||||
val entries = node?.entries?.mapNotNull { (k, v) -> (v as? KeyValues.Node)?.let { k to it } }.orEmpty()
|
||||
val chosen = entries.firstOrNull { it.second.string("MostRecent") == "1" } ?: entries.firstOrNull()
|
||||
chosen?.first?.toLongOrNull()?.minus(76561197960265728L)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The config the client has for app [appId] on the touch controller. Blocking: reads files. */
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
// The app's own saved config is what the client loads when there is one (its configset
|
||||
// entry); otherwise what it logged loading - a template, an official or a workshop config.
|
||||
val file = dir?.let { autosaveConfig(it, appId) }
|
||||
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
if (file == null) {
|
||||
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
|
||||
return Config(null, null, null, emptyMap())
|
||||
}
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val kv = KeyValues.parse(text.removePrefix(""))
|
||||
val mappings = kv.child("controller_mappings") ?: kv
|
||||
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
|
||||
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
|
||||
Config(file, text, layouts, availability(mappings), mappings)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: ${file.path} unreadable: $e")
|
||||
Config(file, null, null, emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTouchConfig(file: File) = file.isFile &&
|
||||
file.bufferedReader().use { r -> generateSequence { r.readLine() }.take(40).any { it.contains("controller_mobile_touch") } }
|
||||
|
||||
/** From the client's console log: the last config it loaded for [appId] on the touch controller
|
||||
* (`Loaded Config for ... App ID <n>, Controller <i>: <path>`, where controller i reported
|
||||
* type 43). Guest paths come back as the app's own. */
|
||||
private fun loggedConfig(context: Context, appId: Int): File? = try {
|
||||
val log = File(steamRoot(context), "logs/console_log.txt")
|
||||
val tail = RandomAccessFile(log, "r").use { raf ->
|
||||
val start = maxOf(0L, raf.length() - 2_000_000L)
|
||||
raf.seek(start)
|
||||
val bytes = ByteArray((raf.length() - start).toInt())
|
||||
raf.readFully(bytes)
|
||||
String(bytes)
|
||||
}
|
||||
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
|
||||
if (touchIndex == null) null else {
|
||||
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
|
||||
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
|
||||
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun autosaveConfig(dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The app's entry in configset_controller_mobile_touch.vdf. */
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
}?.takeIf { it.isFile }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
|
||||
|
||||
// ---- What a config binds ----
|
||||
|
||||
/** Per preset, the controls its active sources bind - what Steam Link shows (UpdateElementAvailability). */
|
||||
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
|
||||
val groups = mappings.children("group").associateBy({ it.string("id") ?: "" }) { group ->
|
||||
group.child("inputs")?.entries?.filter { (_, v) -> (v as? KeyValues.Node)?.let(::hasBinding) == true }
|
||||
?.map { it.first }?.toSet().orEmpty()
|
||||
}
|
||||
return mappings.children("preset").associate { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: 0
|
||||
val set = mutableSetOf<Int>()
|
||||
preset.child("group_source_bindings")?.entries?.forEach { (groupId, value) ->
|
||||
val parts = (value as? String)?.split(' ') ?: return@forEach
|
||||
if (parts.getOrNull(1) != "active" || parts.contains("modeshift")) return@forEach
|
||||
val bound = groups[groupId].orEmpty()
|
||||
when (parts[0]) {
|
||||
"button_diamond" -> mapOf("button_a" to A, "button_b" to B, "button_x" to X, "button_y" to Y)
|
||||
.forEach { (input, element) -> if (input in bound) set += element }
|
||||
"dpad" -> set += DPAD
|
||||
"joystick" -> { set += JOYSTICK_LEFT; if ("click" in bound) set += JOYSTICK_LEFT_BUTTON }
|
||||
"right_joystick" -> { set += JOYSTICK_RIGHT; if ("click" in bound) set += JOYSTICK_RIGHT_BUTTON }
|
||||
"left_trigger" -> set += TRIGGER_LEFT
|
||||
"right_trigger" -> set += TRIGGER_RIGHT
|
||||
"left_trackpad" -> set += TRACKPAD_LEFT
|
||||
"right_trackpad" -> set += TRACKPAD_RIGHT
|
||||
"center_trackpad" -> set += TRACKPAD_CENTER
|
||||
"switch" -> bound.forEach { input ->
|
||||
when (input) {
|
||||
"button_escape" -> set += START
|
||||
"button_menu" -> set += SELECT
|
||||
"left_bumper" -> set += BUMPER_LEFT
|
||||
"right_bumper" -> set += BUMPER_RIGHT
|
||||
"button_macro_1finger" -> set += MACRO_1_FINGER
|
||||
"button_macro_2finger" -> set += MACRO_2_FINGER
|
||||
else -> Regex("button_macro(\\d)").matchEntire(input)?.let { set += MACRO_0 + it.groupValues[1].toInt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id to set.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBinding(input: KeyValues.Node): Boolean =
|
||||
input.child("activators")?.entries?.any { (_, activator) ->
|
||||
(activator as? KeyValues.Node)?.child("bindings")?.entries?.any { (k, v) -> k == "binding" && (v as? String)?.isNotBlank() == true } == true
|
||||
} == true
|
||||
|
||||
// ---- Steam Link's defaults ----
|
||||
|
||||
/** Where Steam Link puts a control with no layout (CVirtualController::BInitializeDefaultElement,
|
||||
* on a 1280x720 screen), and the same scheme for the controls its table leaves out, made room
|
||||
* for when a config binds sticks. */
|
||||
fun defaultElement(type: Int, available: Set<Int>): Element? {
|
||||
val leftStick = JOYSTICK_LEFT in available
|
||||
val rightStick = JOYSTICK_RIGHT in available
|
||||
val faceY = if (rightStick) -0.30f else 0f
|
||||
val p: Pair<Float, Float> = when (type) {
|
||||
THUMB -> 75f / 1280 to 75f / 720
|
||||
STEAM -> 636f / 1280 to 75f / 720
|
||||
SELECT -> 516f / 1280 to 75f / 720
|
||||
START -> 756f / 1280 to 75f / 720
|
||||
KEYBOARD -> 1205f / 1280 to 75f / 720
|
||||
PASTE -> 1115f / 1280 to 75f / 720
|
||||
DPAD -> if (leftStick) 0.147f to 0.47f else 200f / 1280 to 525f / 720
|
||||
A -> 1083f / 1280 to 607f / 720 + faceY
|
||||
B -> 1163f / 1280 to 527f / 720 + faceY
|
||||
X -> 1003f / 1280 to 527f / 720 + faceY
|
||||
Y -> 1083f / 1280 to 447f / 720 + faceY
|
||||
JOYSTICK_LEFT -> 0.137f to 0.76f
|
||||
JOYSTICK_RIGHT -> 0.85f to 0.76f
|
||||
JOYSTICK_LEFT_BUTTON -> 0.29f to 0.88f
|
||||
JOYSTICK_RIGHT_BUTTON -> 0.71f to 0.88f
|
||||
BUMPER_LEFT -> 0.06f to 0.205f
|
||||
TRIGGER_LEFT -> 0.16f to 0.205f
|
||||
BUMPER_RIGHT -> 0.94f to 0.205f
|
||||
TRIGGER_RIGHT -> 0.84f to 0.205f
|
||||
TRACKPAD_LEFT -> 0.3f to 0.55f
|
||||
TRACKPAD_CENTER -> 0.5f to 0.55f
|
||||
TRACKPAD_RIGHT -> 0.7f to 0.55f
|
||||
MACRO_1_FINGER -> 0.42f to 0.92f
|
||||
MACRO_2_FINGER -> 0.58f to 0.92f
|
||||
in MACRO_0..MACRO_0 + 7 -> (0.3f + 0.057f * (type - MACRO_0)) to 0.22f
|
||||
else -> return null
|
||||
}
|
||||
return Element(type, true, p.first, p.second)
|
||||
}
|
||||
|
||||
/** The controls to draw: the layout's own where it has one, Steam Link's defaults otherwise,
|
||||
* only those the config binds. */
|
||||
fun elementsFor(config: Config, actionSet: Int, layers: List<Int>): List<Element> {
|
||||
val available = config.availableFor(actionSet, layers)
|
||||
val layout = config.layouts?.forActionSet(layoutIdOf(actionSet))
|
||||
val placed = layout?.elements?.associateBy { it.type }.orEmpty()
|
||||
val optional = OPTIONAL.filter { placed[it]?.visible == true && it !in available }
|
||||
return (available + optional).mapNotNull { type ->
|
||||
val own = placed[type]
|
||||
when {
|
||||
own != null && !own.visible -> null
|
||||
own != null -> own
|
||||
else -> defaultElement(type, available)
|
||||
}
|
||||
}.sortedBy { it.type }
|
||||
}
|
||||
|
||||
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
|
||||
|
||||
// ---- Saving ----
|
||||
|
||||
/**
|
||||
* Saves [elements] as the layout of [actionSet] in app [appId]'s touch config: into the app's
|
||||
* autosave config (`<appid>/controller_mobile_touch.vdf`, made from the config in use when the
|
||||
* app has none) and selected for the app in the configset, as the client saves a layout Steam
|
||||
* Link sends it. Returns the file written, or null.
|
||||
*/
|
||||
fun saveLayout(
|
||||
context: Context,
|
||||
appId: Int,
|
||||
config: Config,
|
||||
actionSet: Int,
|
||||
elements: List<Element>,
|
||||
color: FloatArray? = null,
|
||||
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
|
||||
): File? = saveLayouts(context, appId, config, mapOf(layoutIdOf(actionSet) to elements), color?.let { c -> setOf(layoutIdOf(actionSet)).associateWith { c } } ?: emptyMap(), bindings)
|
||||
|
||||
/**
|
||||
* Saves the layouts of several action sets (by layout id) at once, with their colours, any
|
||||
* binding edits and the per-game [options], into the game's autosave touch config.
|
||||
*/
|
||||
fun saveLayouts(
|
||||
context: Context,
|
||||
appId: Int,
|
||||
config: Config,
|
||||
sets: Map<Int, List<Element>>,
|
||||
colors: Map<Int, FloatArray> = emptyMap(),
|
||||
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
|
||||
options: Options? = null,
|
||||
): File? {
|
||||
val dir = configDir(context) ?: return null
|
||||
// Icons, labels and colours live in the binding strings (SteamTouchBindings), as the
|
||||
// configurator writes them.
|
||||
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
|
||||
SteamTouchBindings.rewrite(text, ref, change) ?: text
|
||||
}
|
||||
val target = File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
val old = config.layouts
|
||||
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
|
||||
val written = sets.map { (id, elements) ->
|
||||
Layout(id, elements, colors[id] ?: old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f), old?.forActionSet(id)?.version)
|
||||
}
|
||||
val rest = old?.rest ?: byteArrayOf(0x10, 0x02) // input_mode = controller
|
||||
val layouts = Layouts((keep + written).sortedBy { it.actionSet }, options?.encodeInto(rest) ?: rest)
|
||||
val hex = bytesToHex(encodeLayouts(layouts))
|
||||
val line = "\t\"touch_layout\"\t\t\"$hex\""
|
||||
val existing = Regex("""\n\s*"touch_layout"\s+"[0-9a-fA-F]*"""")
|
||||
val text = if (existing.containsMatchIn(source)) source.replace(existing, "\n$line")
|
||||
else source.replaceFirst(Regex("""("controller_type"\s+"controller_mobile_touch"[^\n]*)"""), "$1\n$line")
|
||||
if (!text.contains("touch_layout")) return null
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
/** Points the app's configset entry at its autosave config. */
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
|
||||
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
|
||||
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
|
||||
}
|
||||
|
||||
// ---- touch_layout: CVirtualControllerLayouts ----
|
||||
|
||||
fun decodeLayouts(bytes: ByteArray): Layouts {
|
||||
val layouts = mutableListOf<Layout>()
|
||||
val rest = ByteArrayOutputStream()
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val start = p.pos
|
||||
val (field, wire) = p.key()
|
||||
if (field == 1 && wire == 2) layouts += decodeLayout(p.bytes())
|
||||
else { p.skip(wire); rest.write(bytes, start, p.pos - start) }
|
||||
}
|
||||
return Layouts(layouts, rest.toByteArray())
|
||||
}
|
||||
|
||||
private fun decodeLayout(bytes: ByteArray): Layout {
|
||||
var actionSet = 0
|
||||
var version: Int? = null
|
||||
var color: FloatArray? = null
|
||||
val elements = mutableListOf<Element>()
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 1 && wire == 0 -> version = p.varint().toInt()
|
||||
field == 2 && wire == 0 -> actionSet = p.varint().toInt()
|
||||
field == 4 && wire == 2 -> elements += decodeElement(p.bytes())
|
||||
field == 5 && wire == 2 -> color = decodeColor(p.bytes())
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Layout(actionSet, elements, color, version)
|
||||
}
|
||||
|
||||
private fun decodeElement(bytes: ByteArray): Element {
|
||||
var type = -1
|
||||
var visible = false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var sx = 1f
|
||||
var sy = 1f
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 1 && wire == 0 -> type = p.varint().toInt()
|
||||
field == 2 && wire == 0 -> visible = p.varint() != 0L
|
||||
field == 3 && wire == 5 -> x = p.float()
|
||||
field == 4 && wire == 5 -> y = p.float()
|
||||
field == 5 && wire == 5 -> sx = p.float()
|
||||
field == 6 && wire == 5 -> sy = p.float()
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Element(type, visible, x, y, sx, sy)
|
||||
}
|
||||
|
||||
private fun decodeColor(bytes: ByteArray): FloatArray {
|
||||
val c = floatArrayOf(1f, 1f, 1f, 1f)
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
if (field in 1..4 && wire == 5) c[field - 1] = p.float() else p.skip(wire)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
fun encodeLayouts(layouts: Layouts): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
layouts.layouts.forEach { layout ->
|
||||
val l = ByteArrayOutputStream()
|
||||
layout.version?.let { tag(l, 1, 0); varint(l, it.toLong()) }
|
||||
tag(l, 2, 0); varint(l, layout.actionSet.toLong())
|
||||
layout.elements.forEach { e ->
|
||||
val m = ByteArrayOutputStream()
|
||||
tag(m, 1, 0); varint(m, e.type.toLong())
|
||||
tag(m, 2, 0); varint(m, if (e.visible) 1 else 0)
|
||||
tag(m, 3, 5); fixed(m, e.x)
|
||||
tag(m, 4, 5); fixed(m, e.y)
|
||||
tag(m, 5, 5); fixed(m, e.xScale)
|
||||
tag(m, 6, 5); fixed(m, e.yScale)
|
||||
tag(l, 4, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
layout.color?.let { c ->
|
||||
val m = ByteArrayOutputStream()
|
||||
for (i in 0 until 4) { tag(m, i + 1, 5); fixed(m, c[i]) }
|
||||
tag(l, 5, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
tag(out, 1, 2); bytes(out, l.toByteArray())
|
||||
}
|
||||
out.write(layouts.rest)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun tag(out: ByteArrayOutputStream, field: Int, wire: Int) = varint(out, (field shl 3 or wire).toLong())
|
||||
private fun varint(out: ByteArrayOutputStream, value: Long) {
|
||||
var v = value
|
||||
while (true) {
|
||||
if (v and 0x7fL.inv() == 0L) { out.write(v.toInt()); return }
|
||||
out.write((v and 0x7f or 0x80).toInt())
|
||||
v = v ushr 7
|
||||
}
|
||||
}
|
||||
private fun fixed(out: ByteArrayOutputStream, f: Float) {
|
||||
val bits = java.lang.Float.floatToIntBits(f)
|
||||
for (i in 0 until 4) out.write(bits ushr (8 * i) and 0xff)
|
||||
}
|
||||
private fun bytes(out: ByteArrayOutputStream, b: ByteArray) { varint(out, b.size.toLong()); out.write(b) }
|
||||
|
||||
private class Proto(val data: ByteArray) {
|
||||
var pos = 0
|
||||
fun varint(): Long {
|
||||
var shift = 0
|
||||
var result = 0L
|
||||
while (pos < data.size) {
|
||||
val b = data[pos++].toInt() and 0xff
|
||||
result = result or ((b and 0x7f).toLong() shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun float(): Float {
|
||||
var bits = 0
|
||||
for (i in 0 until 4) bits = bits or ((data[pos + i].toInt() and 0xff) shl (8 * i))
|
||||
pos += 4
|
||||
return java.lang.Float.intBitsToFloat(bits)
|
||||
}
|
||||
fun bytes(): ByteArray {
|
||||
val n = varint().toInt()
|
||||
return data.copyOfRange(pos, pos + n).also { pos += n }
|
||||
}
|
||||
fun skip(wire: Int) {
|
||||
when (wire) {
|
||||
0 -> varint()
|
||||
1 -> pos += 8
|
||||
2 -> pos += varint().toInt()
|
||||
5 -> pos += 4
|
||||
else -> pos = data.size
|
||||
}
|
||||
}
|
||||
fun more() = pos < data.size
|
||||
fun key(): Pair<Int, Int> = varint().toInt().let { (it ushr 3) to (it and 7) }
|
||||
}
|
||||
|
||||
fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
|
||||
fun bytesToHex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Valve's KeyValues text format, as far as controller configs use it. */
|
||||
class KeyValues private constructor() {
|
||||
class Node(val entries: MutableList<Pair<String, Any>> = mutableListOf()) {
|
||||
fun child(key: String): Node? = entries.firstOrNull { it.first.equals(key, true) && it.second is Node }?.second as Node?
|
||||
fun children(key: String): List<Node> = entries.filter { it.first.equals(key, true) && it.second is Node }.map { it.second as Node }
|
||||
fun string(key: String): String? = entries.firstOrNull { it.first.equals(key, true) && it.second is String }?.second as String?
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(text: String): Node {
|
||||
val tokens = tokenize(text)
|
||||
var i = 0
|
||||
fun node(): Node {
|
||||
val n = Node()
|
||||
while (i < tokens.size) {
|
||||
val t = tokens[i++]
|
||||
if (t == "}" ) return n
|
||||
if (t == "{") continue
|
||||
if (i >= tokens.size) break
|
||||
val next = tokens[i]
|
||||
if (next == "{") { i++; n.entries += t to node() } else { i++; n.entries += t to next }
|
||||
}
|
||||
return n
|
||||
}
|
||||
return node()
|
||||
}
|
||||
|
||||
private fun tokenize(text: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += c.toString(); i++ }
|
||||
c == '"' -> {
|
||||
val sb = StringBuilder()
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') {
|
||||
if (text[i] == '\\' && i + 1 < text.length) {
|
||||
i++
|
||||
sb.append(when (text[i]) { 'n' -> '\n'; 't' -> '\t'; else -> text[i] })
|
||||
} else sb.append(text[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
out += sb.toString()
|
||||
}
|
||||
c == '[' -> { while (i < text.length && text[i] != ']') i++; i++ }
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += text.substring(start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/**
|
||||
* Steam's touch controller, as the app's side of it.
|
||||
*
|
||||
* libfakeinput offers the Steam client a HID device with the ids of Steam Link's touch controls
|
||||
* (0000:11fb), and the client builds its own touch controller for it: Steam Input's Mobile Touch
|
||||
* type, with the game's touch config, action sets, community configs and cloud sync all Steam's
|
||||
* (docs/development/steam-touch-controller.md). This class is the file the two share
|
||||
* (fakeinput_steam.cpp: TouchRingFile): the app writes the device's 40-byte input report and
|
||||
* whether it is plugged in; libfakeinput writes back what the client told the device - the app and
|
||||
* action set it is on, its layers, rumble.
|
||||
*/
|
||||
class SteamTouchDevice private constructor(private val buffer: ByteBuffer) {
|
||||
|
||||
/** What the client last told the device. */
|
||||
data class State(
|
||||
val opened: Int,
|
||||
val appId: Int,
|
||||
val actionSet: Int,
|
||||
val layers: List<Int>,
|
||||
val actionSeq: Int,
|
||||
)
|
||||
|
||||
// The input report (Steam Link's CVirtualController, docs/development/steam-touch-controller.md):
|
||||
// bytes 0-7 buttons, 8-15 sticks, 16-27 trackpads, 28-33 accelerometer, 34-39 gyro.
|
||||
private val report = ByteArray(REPORT_BYTES)
|
||||
private var reportSeq = 0L
|
||||
|
||||
/** The controls part of the report: button bits, sticks (LX, LY, RX, RY, already in the report's
|
||||
* units) and trackpads (centre, left, right: x, y each). */
|
||||
@Synchronized
|
||||
fun setControls(buttons: Long, sticks: ShortArray, pads: ShortArray) {
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 8) next[i] = (buttons ushr (8 * i)).toByte()
|
||||
for (i in 0 until 4) putShort(next, 8 + 2 * i, sticks[i])
|
||||
for (i in 0 until 6) putShort(next, 16 + 2 * i, pads[i])
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Motion in the Deck's axes and units (PadMotion), which the touch report shares. */
|
||||
@Synchronized
|
||||
fun setMotion(accel: ShortArray, gyro: ShortArray) {
|
||||
// Sent only while the client asks for it (setting report 0x30, which Steam Link reads as
|
||||
// "gyro on") and the player has not turned motion off - as Steam Link does.
|
||||
if (!plugged || !gyroRequested || !motionEnabled) return
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 3) {
|
||||
// Accelerometer: the Deck's 1 g = 16384 is the touch report's ±2 g = ±32767 already.
|
||||
putShort(next, 28 + 2 * i, accel[i])
|
||||
// Gyro: the Deck's 2000 °/s = 32768; the touch report's full scale is 1000 °/s
|
||||
// (Steam Link: rad/s × 180/π / 1000 × 32767).
|
||||
putShort(next, 34 + 2 * i, (gyro[i] * 2).coerceIn(-32767, 32767).toShort())
|
||||
}
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Whether the client has asked the device for motion (setting report 0x30 non-zero). */
|
||||
val gyroRequested get() = buffer.getInt(OFF_SETTING30) != 0
|
||||
|
||||
/** The player's motion switch (Steam Link's gyroscope option). */
|
||||
@Volatile var motionEnabled = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (!value) synchronized(this) {
|
||||
val next = report.copyOf()
|
||||
for (i in 28 until 40) next[i] = 0
|
||||
publish(next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putShort(into: ByteArray, at: Int, value: Short) {
|
||||
into[at] = value.toInt().toByte()
|
||||
into[at + 1] = (value.toInt() shr 8).toByte()
|
||||
}
|
||||
|
||||
/** Writes the report under the seqlock libfakeinput reads it with. */
|
||||
private fun publish(next: ByteArray) {
|
||||
if (next.contentEquals(report)) return
|
||||
next.copyInto(report)
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
for (i in 0 until REPORT_BYTES) buffer.put(OFF_REPORT + i, report[i])
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
}
|
||||
|
||||
var plugged: Boolean
|
||||
get() = buffer.getInt(OFF_PLUGGED) != 0
|
||||
set(value) {
|
||||
if (value == plugged) return
|
||||
if (!value) synchronized(this) { publish(ByteArray(REPORT_BYTES)) }
|
||||
buffer.putInt(OFF_PLUGGED, if (value) 1 else 0)
|
||||
Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged")
|
||||
}
|
||||
|
||||
fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100))
|
||||
|
||||
fun state(): State {
|
||||
repeat(8) {
|
||||
val seq = buffer.getLong(OFF_STATE_SEQ)
|
||||
if (seq and 1L != 0L) return@repeat
|
||||
val count = buffer.getInt(OFF_LAYER_COUNT).coerceIn(0, 8)
|
||||
val read = State(
|
||||
opened = buffer.getInt(OFF_OPENED),
|
||||
appId = buffer.getInt(OFF_APPID),
|
||||
actionSet = buffer.getInt(OFF_ACTION_SET),
|
||||
layers = (0 until count).map { buffer.getInt(OFF_LAYERS + 4 * it) },
|
||||
actionSeq = buffer.getInt(OFF_ACTION_SEQ),
|
||||
)
|
||||
if (seq == buffer.getLong(OFF_STATE_SEQ)) return read
|
||||
}
|
||||
return State(0, 0, 0, emptyList(), -1)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamTouch"
|
||||
const val REPORT_BYTES = 40
|
||||
private const val SIZE = 256
|
||||
private const val MAGIC = 0x31484354 // TCH1
|
||||
private const val OFF_REPORT_SEQ = 8
|
||||
private const val OFF_REPORT = 16
|
||||
private const val OFF_PLUGGED = 56
|
||||
private const val OFF_BATTERY = 60
|
||||
private const val OFF_STATE_SEQ = 64
|
||||
private const val OFF_OPENED = 72
|
||||
private const val OFF_APPID = 76
|
||||
private const val OFF_ACTION_SET = 80
|
||||
private const val OFF_LAYER_COUNT = 84
|
||||
private const val OFF_LAYERS = 88
|
||||
private const val OFF_SETTING30 = 132
|
||||
private const val OFF_ACTION_SEQ = 136
|
||||
|
||||
@Volatile
|
||||
var current: SteamTouchDevice? = null
|
||||
private set
|
||||
|
||||
/** The shared file in the session's pad directory, made fresh each session. */
|
||||
fun ringFile(fakeInputDir: File) = File(fakeInputDir, "touchctl")
|
||||
|
||||
/** Creates the file for a new session (unplugged) and maps it. */
|
||||
fun prepare(fakeInputDir: File): File? = try {
|
||||
val file = ringFile(fakeInputDir)
|
||||
file.delete()
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.setLength(SIZE.toLong())
|
||||
val map = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()).order(ByteOrder.LITTLE_ENDIAN)
|
||||
map.putInt(4, 1)
|
||||
map.putInt(0, MAGIC)
|
||||
current = SteamTouchDevice(map)
|
||||
}
|
||||
file.setReadable(true, false)
|
||||
file.setWritable(true, false)
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: no shared file: $e")
|
||||
current = null
|
||||
null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
current?.plugged = false
|
||||
current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.GridView
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Picks a touch control's icon, label and colours the way Steam's configurator does: an icon from
|
||||
* the game's own `TouchMenuIcons` and Steam's binding icon library, a foreground and a background
|
||||
* from Steam's palette. [onPicked] gets the binding with the new fields (the action untouched).
|
||||
*/
|
||||
class SteamTouchIconPicker(
|
||||
private val context: Context,
|
||||
private val host: ViewGroup,
|
||||
private val appId: Int,
|
||||
private val title: String,
|
||||
private val current: SteamTouchBindings.Binding,
|
||||
private val onPicked: (SteamTouchBindings.Binding) -> Unit,
|
||||
) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val loader = Executors.newFixedThreadPool(2)
|
||||
private var icon = current.icon
|
||||
private var foreground = current.foreground.ifEmpty { SteamTouchBindings.DEFAULT_FOREGROUND }
|
||||
private var background = current.background.ifEmpty { SteamTouchBindings.DEFAULT_BACKGROUND }
|
||||
private val density = context.resources.displayMetrics.density
|
||||
|
||||
private fun dp(v: Int) = (v * density).toInt()
|
||||
|
||||
fun show() {
|
||||
// Side by side, for a landscape screen: what is picked on the left, the icons on the right.
|
||||
val root = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; setPadding(dp(16), dp(8), dp(16), 0) }
|
||||
val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
|
||||
val label = EditText(context).apply {
|
||||
hint = "Label (optional)"
|
||||
setText(current.label)
|
||||
inputType = InputType.TYPE_CLASS_TEXT
|
||||
setSingleLine()
|
||||
}
|
||||
left.addView(label)
|
||||
val preview = ImageView(context)
|
||||
val names = SteamTouchBindings.iconNames(context, appId)
|
||||
val grid = GridView(context).apply {
|
||||
numColumns = GridView.AUTO_FIT
|
||||
columnWidth = dp(56)
|
||||
stretchMode = GridView.STRETCH_COLUMN_WIDTH
|
||||
verticalSpacing = dp(6)
|
||||
horizontalSpacing = dp(6)
|
||||
}
|
||||
val adapter = IconAdapter(names)
|
||||
grid.adapter = adapter
|
||||
grid.setOnItemClickListener { _, _, position, _ ->
|
||||
icon = names[position]
|
||||
adapter.notifyDataSetChanged()
|
||||
updatePreview(preview)
|
||||
}
|
||||
left.addView(LinearLayout(context).apply {
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
addView(preview, LinearLayout.LayoutParams(dp(56), dp(56)))
|
||||
addView(TextView(context).apply {
|
||||
text = if (names.isEmpty()) "Steam's icons were not found" else "${names.size} icons from Steam"
|
||||
setPadding(dp(12), 0, 0, 0)
|
||||
})
|
||||
})
|
||||
left.addView(paletteRow("Icon colour", { foreground }) { foreground = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
|
||||
left.addView(paletteRow("Button colour", { background }) { background = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
|
||||
root.addView(left, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
root.addView(grid, LinearLayout.LayoutParams(0, dp(250), 1.2f).apply { marginStart = dp(16) })
|
||||
updatePreview(preview)
|
||||
showPanel(host, title, root, listOf(
|
||||
"No icon" to {
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
|
||||
},
|
||||
"Cancel" to {},
|
||||
"Use" to {
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
|
||||
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
|
||||
},
|
||||
)) { loader.shutdown() }
|
||||
}
|
||||
|
||||
private fun paletteRow(name: String, value: () -> String, onPick: (String) -> Unit): View {
|
||||
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
|
||||
val swatches = mutableListOf<View>()
|
||||
fun refresh() = swatches.forEachIndexed { i, v ->
|
||||
(v.background as GradientDrawable).setStroke(dp(if (SteamTouchBindings.PALETTE[i].equals(value(), true)) 3 else 1),
|
||||
if (SteamTouchBindings.PALETTE[i].equals(value(), true)) Color.rgb(102, 192, 244) else Color.GRAY)
|
||||
}
|
||||
SteamTouchBindings.PALETTE.forEach { hex ->
|
||||
val swatch = View(context).apply {
|
||||
background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.parseColor(hex)) }
|
||||
setOnClickListener { onPick(hex); refresh() }
|
||||
}
|
||||
swatches += swatch
|
||||
row.addView(swatch, LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
|
||||
}
|
||||
refresh()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
addView(TextView(context).apply { text = name })
|
||||
addView(HorizontalScrollView(context).apply { addView(row) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreview(view: ImageView) {
|
||||
if (icon.isEmpty()) { view.setImageDrawable(null); view.background = null; return }
|
||||
loader.execute {
|
||||
val bitmap = SteamTouchBindings.icon(context, appId, icon, 128)?.let { render(it, dp(56)) }
|
||||
main.post { view.setImageBitmap(bitmap) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon as Steam draws it: on a disc of the background colour, filled with the foreground
|
||||
* through its own alpha. */
|
||||
private fun render(icon: Bitmap, size: Int): Bitmap {
|
||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(out)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
paint.color = Color.parseColor(background)
|
||||
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||
drawSteamIcon(canvas, icon, android.graphics.RectF(size * 0.18f, size * 0.18f, size * 0.82f, size * 0.82f), Color.parseColor(foreground), paint)
|
||||
return out
|
||||
}
|
||||
|
||||
private inner class IconAdapter(val names: List<String>) : BaseAdapter() {
|
||||
private val loading = HashSet<String>()
|
||||
private var refreshQueued = false
|
||||
|
||||
/** Loads an icon in the background; the grid redraws from the cache once some have come in. */
|
||||
private fun load(name: String) {
|
||||
if (!loading.add(name)) return
|
||||
loader.execute {
|
||||
SteamTouchBindings.icon(context, appId, name, 96)
|
||||
main.post {
|
||||
loading.remove(name)
|
||||
if (refreshQueued) return@post
|
||||
refreshQueued = true
|
||||
main.postDelayed({ refreshQueued = false; notifyDataSetChanged() }, 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCount() = names.size
|
||||
override fun getItem(position: Int) = names[position]
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val view = (convertView as? ImageView) ?: ImageView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56))
|
||||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||||
}
|
||||
val name = names[position]
|
||||
view.tag = name
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(8).toFloat()
|
||||
setColor(Color.parseColor(background))
|
||||
if (name == icon) setStroke(dp(3), Color.rgb(102, 192, 244))
|
||||
}
|
||||
view.colorFilter = null
|
||||
val cached = SteamTouchBindings.cachedIcon(appId, name, 96)
|
||||
if (cached != null) {
|
||||
val t = tinted(name, cached)
|
||||
if ((view.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap !== t) view.setImageBitmap(t)
|
||||
} else {
|
||||
view.setImageDrawable(null)
|
||||
load(name)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// One thumbnail per icon and colour, reused when the grid rebinds.
|
||||
private val thumbs = android.util.LruCache<String, Bitmap>(64)
|
||||
|
||||
private fun tinted(name: String, icon: Bitmap): Bitmap {
|
||||
val key = "$name|$foreground"
|
||||
thumbs.get(key)?.let { return it }
|
||||
val out = Bitmap.createBitmap(icon.width, icon.height, Bitmap.Config.ARGB_8888)
|
||||
drawSteamIcon(Canvas(out), icon, android.graphics.RectF(0f, 0f, icon.width.toFloat(), icon.height.toFloat()),
|
||||
Color.parseColor(foreground), Paint(Paint.FILTER_BITMAP_FLAG))
|
||||
thumbs.put(key, out)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A panel over the session, in the session's own window: a dialog's window of its own left
|
||||
* new bitmaps and layers undrawn in the session window afterwards on the Thor.
|
||||
*/
|
||||
fun showPanel(host: ViewGroup, title: String, content: View, buttons: List<Pair<String, () -> Unit>>, onClose: () -> Unit = {}): () -> Unit {
|
||||
val context = host.context
|
||||
val density = context.resources.displayMetrics.density
|
||||
fun dp(v: Int) = (v * density).toInt()
|
||||
val scrim = android.widget.FrameLayout(context).apply {
|
||||
setBackgroundColor(Color.argb(140, 0, 0, 0))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
// DroidDeck's panel: dark surface, rounded, a hairline border.
|
||||
val card = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(18).toFloat(); setColor(Color.rgb(18, 20, 23)); setStroke(dp(1), Color.rgb(38, 42, 49))
|
||||
}
|
||||
setPadding(dp(12), dp(14), dp(12), dp(10))
|
||||
isClickable = true
|
||||
}
|
||||
card.addView(TextView(context).apply {
|
||||
text = title
|
||||
textSize = 15f
|
||||
setTypeface(android.graphics.Typeface.DEFAULT_BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(Color.WHITE)
|
||||
setPadding(dp(16), 0, dp(16), dp(4))
|
||||
})
|
||||
card.addView(content)
|
||||
val row = LinearLayout(context).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), 0) }
|
||||
fun close() {
|
||||
(scrim.parent as? ViewGroup)?.removeView(scrim)
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(host.windowToken, 0)
|
||||
onClose()
|
||||
}
|
||||
buttons.forEachIndexed { i, (name, action) ->
|
||||
row.addView(TextView(context).apply {
|
||||
text = name
|
||||
gravity = Gravity.CENTER
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
val primary = i == buttons.size - 1
|
||||
setTextColor(if (primary) Color.rgb(3, 17, 31) else Color.rgb(242, 244, 247))
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(12).toFloat()
|
||||
if (primary) setColor(Color.rgb(26, 159, 255)) else { setColor(Color.rgb(26, 29, 34)); setStroke(dp(1), Color.rgb(52, 58, 67)) }
|
||||
}
|
||||
setPadding(dp(20), dp(10), dp(20), dp(10))
|
||||
setOnClickListener { close(); action() }
|
||||
}, LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginStart = dp(6); marginEnd = dp(6) })
|
||||
}
|
||||
card.addView(row)
|
||||
val width = minOf(dp(640), (context.resources.displayMetrics.widthPixels * 0.9f).toInt())
|
||||
scrim.addView(card, android.widget.FrameLayout.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
scrim.setOnClickListener { close() }
|
||||
host.addView(scrim, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
return { if (scrim.parent != null) close() }
|
||||
}
|
||||
|
||||
/** Steam's binding icon look (steamui: the icon multiplied with its foreground colour,
|
||||
* background-blend-mode: multiply, keeping the icon's own alpha). */
|
||||
fun drawSteamIcon(canvas: Canvas, icon: Bitmap, into: android.graphics.RectF, foreground: Int, paint: Paint) {
|
||||
val tint = Paint(paint).apply { colorFilter = PorterDuffColorFilter(foreground, PorterDuff.Mode.MULTIPLY) }
|
||||
canvas.drawBitmap(icon, null, into, tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ object AppImageManager {
|
||||
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
|
||||
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
|
||||
// here into the user's with a GPU launcher (droiddeck-desktop-gpu), since it composites in software.
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
|
||||
|
||||
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
|
||||
@@ -256,6 +256,20 @@ object AppImageManager {
|
||||
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
|
||||
}
|
||||
|
||||
/** The desktop's import of [key]; older ones of the image at [path], since replaced, are removed. */
|
||||
fun desktopImport(context: Context, key: String, path: String): String? {
|
||||
var found: String? = null
|
||||
root(context).listFiles()?.forEach { dir ->
|
||||
val asked = FileUtils.readString(File(dir, UserApps.DESKTOP_IMPORT))?.lines() ?: return@forEach
|
||||
if (asked.firstOrNull() == key) {
|
||||
if (File(dir, "app").isDirectory) found = dir.name
|
||||
} else if (asked.getOrNull(1) == path) {
|
||||
remove(context, dir.name)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
|
||||
FileUtils.delete(File(root(context), id))
|
||||
|
||||
@@ -232,10 +232,10 @@ object BwrapSpawner {
|
||||
val driver = File(lib, "libvulkan_freedreno.so")
|
||||
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
|
||||
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
|
||||
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
|
||||
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
|
||||
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
|
||||
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
|
||||
// A window on the desktop gets shared-memory buffers only (KWin composites in software);
|
||||
// Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe). From the
|
||||
// desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
@@ -19,12 +20,12 @@ import java.io.File
|
||||
*/
|
||||
object DesktopCatalog {
|
||||
private const val TAG = "DesktopCatalog"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
/**
|
||||
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
|
||||
* In a catalog of its own so it never shows among the packages a user picks from.
|
||||
*/
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
|
||||
class Entry(
|
||||
@@ -74,10 +75,46 @@ object DesktopCatalog {
|
||||
installed(context, PROTON_SEED_ID) == null &&
|
||||
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
|
||||
|
||||
// labwc comes with the hosted desktop package itself (its launcher is staged by the app at
|
||||
// every session, so it cannot tell whether the package is there); SessionFiles uses the same test.
|
||||
fun desktopInstalled(context: Context): Boolean =
|
||||
File(LinuxRuntime.rootDir(context), "usr/bin/labwc").isFile
|
||||
/**
|
||||
* The desktop: KDE Plasma (tools/desktop-kde). It replaced an LXQt/labwc package of id
|
||||
* "desktop", which a runtime may still carry; that one does not count, so the first desktop
|
||||
* after the update installs this one over it.
|
||||
*/
|
||||
const val DESKTOP_ID = "desktop-kde"
|
||||
|
||||
/**
|
||||
* The desktop package this apk installs: pinned in tools/desktop-kde/release.env and published
|
||||
* in DroidDeck-Components, not read from the catalog, so the desktop scripts staged by this
|
||||
* apk always get the package they were made for.
|
||||
*/
|
||||
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, "",
|
||||
"", "",
|
||||
)
|
||||
|
||||
// The desktop package installed to the end: KWin is there and the marker install() writes once
|
||||
// the whole package is extracted. An install cut short (the app killed, storage full) leaves
|
||||
// KWin without the marker, and the next desktop installs it again. A newer pinned package also
|
||||
// replaces the installed one, keeping the user's home and settings. The launcher is staged by
|
||||
// the app at every session, so it cannot tell. SessionFiles uses the same test.
|
||||
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
|
||||
|
||||
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
|
||||
|
||||
fun desktopPresent(context: Context): Boolean {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
|
||||
}
|
||||
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
|
||||
DesktopRemoval.cleanLegacy(context, listener)
|
||||
|
||||
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
|
||||
DesktopRemoval.remove(context, listener)
|
||||
|
||||
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
|
||||
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
|
||||
@@ -111,9 +148,20 @@ object DesktopCatalog {
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
else -> {
|
||||
// Not complete until written again at the end: a reinstall cut short must not
|
||||
// leave the last install's marker saying it is.
|
||||
marker(context, entry.id).delete()
|
||||
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
}
|
||||
}
|
||||
if (entry.id == DESKTOP_ID) {
|
||||
cleanLegacy(context, listener)
|
||||
val pending = File(root, DesktopRemoval.PENDING)
|
||||
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
|
||||
}
|
||||
FileUtils.writeString(marker(context, entry.id), entry.version)
|
||||
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${entry.id}", e)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Removes only unchanged, recorded package files. Never visits home or follows a symlink. */
|
||||
internal object DesktopFileRemoval {
|
||||
data class Entry(val kind: String, val size: Long, val identity: String, val path: String) {
|
||||
companion object {
|
||||
fun parse(line: String): Entry {
|
||||
val parts = line.split('\t', limit = 4)
|
||||
require(parts.size == 4 && parts[0] in setOf("F", "L")) { "Invalid removal inventory" }
|
||||
return Entry(parts[0], parts[1].toLong(), parts[2], parts[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
data class Result(val removed: Int, val bytes: Long, val kept: Int)
|
||||
|
||||
fun remove(root: File, entries: Iterable<Entry>, protected: Set<String>, progress: (Int) -> Unit = {}): Result {
|
||||
var removed = 0
|
||||
var bytes = 0L
|
||||
var kept = 0
|
||||
val buffer = ByteArray(65536)
|
||||
val total = (entries as? Collection<*>)?.size ?: 0
|
||||
val directories = mutableSetOf<File>()
|
||||
for ((index, entry) in entries.withIndex()) {
|
||||
val file = safeFile(root, entry.path)
|
||||
if (file == null || entry.path in protected) { kept++; continue }
|
||||
val attrs = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) }
|
||||
catch (_: java.nio.file.NoSuchFileException) { continue }
|
||||
val matches = when (entry.kind) {
|
||||
"L" -> attrs.isSymbolicLink && Files.readSymbolicLink(file.toPath()).toString() == entry.identity
|
||||
else -> attrs.isRegularFile && attrs.size() == entry.size && sha(file, buffer) == entry.identity
|
||||
}
|
||||
if (!matches) { kept++; continue }
|
||||
Files.delete(file.toPath()) // A failure propagates; the pending marker makes retry possible.
|
||||
removed++
|
||||
bytes += if (attrs.isRegularFile) attrs.size() else 0
|
||||
var parent = file.parentFile
|
||||
while (parent != root && parent != null) { directories.add(parent); parent = parent.parentFile }
|
||||
if (index % 200 == 0) progress(if (total > 0) index * 99 / total else -1)
|
||||
}
|
||||
// Only now-empty parents of removed files, never a recursive delete of a package directory.
|
||||
for (dir in directories.sortedByDescending { it.path.length }) {
|
||||
if (dir.list()?.isEmpty() == true && !dir.delete()) throw IOException("Cannot remove $dir")
|
||||
}
|
||||
progress(99)
|
||||
return Result(removed, bytes, kept)
|
||||
}
|
||||
|
||||
internal fun safeFile(root: File, name: String): File? {
|
||||
if (Files.isSymbolicLink(root.toPath())) return null
|
||||
val parts = name.split('/')
|
||||
if (parts.firstOrNull() !in setOf("usr", "etc") || parts.any { it.isEmpty() || it == "." || it == ".." }) return null
|
||||
// Even an in-root symlink in a parent can redirect deletion into a user's data.
|
||||
var parent = root
|
||||
for (part in parts.dropLast(1)) {
|
||||
parent = File(parent, part)
|
||||
if (Files.isSymbolicLink(parent.toPath())) return null
|
||||
}
|
||||
return File(root, name)
|
||||
}
|
||||
|
||||
internal fun sha(file: File, buffer: ByteArray = ByteArray(65536)): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
|
||||
}
|
||||
val digits = "0123456789abcdef"
|
||||
return buildString(64) {
|
||||
for (byte in digest.digest()) { val b = byte.toInt() and 255; append(digits[b ushr 4]); append(digits[b and 15]) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The base runtime's pacman database is still present: desktop archives never replace it. */
|
||||
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")
|
||||
for (pkg in database.listFiles().orEmpty()) {
|
||||
val files = File(pkg, "files")
|
||||
if (!pkg.isDirectory || Files.isSymbolicLink(pkg.toPath()) || !files.isFile || Files.isSymbolicLink(files.toPath())) continue
|
||||
var inFiles = false
|
||||
files.forEachLine { line ->
|
||||
when {
|
||||
line == "%FILES%" -> inFiles = true
|
||||
line.startsWith('%') -> inFiles = false
|
||||
inFiles && line.isNotEmpty() && !line.endsWith('/') -> add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isEmpty()) throw IOException("Runtime package inventory missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
/** Inventories are generated from pinned archives by tools/desktop-kde/removal-manifests.py. */
|
||||
internal object DesktopRemoval {
|
||||
const val PENDING = ".droiddeck-desktop-removing"
|
||||
private const val CLEANING = ".droiddeck-lxqt-cleaning-v1"
|
||||
private const val CLEANED = ".droiddeck-lxqt-cleaned-v1"
|
||||
|
||||
private fun lines(context: Context, name: String) =
|
||||
GZIPInputStream(context.assets.open("desktop-removal/$name")).bufferedReader().use { it.readLines() }
|
||||
|
||||
private fun protected(context: Context, root: File) =
|
||||
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
|
||||
|
||||
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (FileUtils.readString(File(root, CLEANED))?.trim() == "1") return
|
||||
if (!File(root, CLEANING).exists() && !File(root, ".droiddeck-pkg-desktop").exists() && !File(root, "usr/bin/lxqt-session").exists()) return
|
||||
listener?.onProgress(context.getString(R.string.desktop_cleaning_legacy), -1)
|
||||
val keep = protected(context, root) + lines(context, "kde-keep.txt.gzip")
|
||||
val pending = File(root, CLEANING)
|
||||
FileUtils.writeString(pending, "1")
|
||||
check(FileUtils.readString(pending)?.trim() == "1") { "Could not reserve LXQt cleanup" }
|
||||
val result = GZIPInputStream(context.assets.open("desktop-removal/lxqt-r1.tsv.gzip")).bufferedReader().use { reader ->
|
||||
DesktopFileRemoval.remove(root, reader.lineSequence().map(DesktopFileRemoval.Entry::parse).asIterable(), keep)
|
||||
}
|
||||
FileUtils.writeString(File(root, CLEANED), "1")
|
||||
check(FileUtils.readString(File(root, CLEANED))?.trim() == "1") { "Could not finish LXQt cleanup" }
|
||||
File(root, ".droiddeck-pkg-desktop").delete()
|
||||
pending.delete()
|
||||
Log.i("DesktopRemoval", "LXQt: $result")
|
||||
}
|
||||
|
||||
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
val pending = File(root, PENDING)
|
||||
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
|
||||
val version = FileUtils.readString(pending)?.trim() ?: FileUtils.readString(marker)?.trim()
|
||||
val versions = context.assets.open("desktop-removal/versions.txt").bufferedReader().use { it.readLines() }
|
||||
check(version in versions) { context.getString(R.string.desktop_removal_unknown) }
|
||||
// Validate all inventories and the runtime database before dropping the installed marker.
|
||||
val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse)
|
||||
val keep = protected(context, 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) }
|
||||
// APK-staged desktop files are matched against the APK too; keep user-modified copies.
|
||||
for ((asset, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
|
||||
val target = DesktopFileRemoval.safeFile(root, path) ?: continue
|
||||
if (!isRegularFile(target)) continue
|
||||
val content = context.assets.open("linuxfs/$asset").use { it.readBytes() }
|
||||
if (target.length() == content.size.toLong() && target.inputStream().use { it.readBytes() }.contentEquals(content)) {
|
||||
if (!target.delete()) error("Could not remove $path")
|
||||
}
|
||||
}
|
||||
if (!pending.delete()) error("Could not finish desktop removal")
|
||||
Log.i("DesktopRemoval", "KDE: $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 fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||
}
|
||||
@@ -87,15 +87,16 @@ object FlatpakManager {
|
||||
?.maxByOrNull { it.first }?.second
|
||||
}
|
||||
|
||||
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
|
||||
/** Runs [argv] in the runtime, its output also in files/logs/tools/flatpak-<verb>.log. */
|
||||
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
|
||||
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
|
||||
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
|
||||
}
|
||||
|
||||
private inline fun <T> exclusive(what: String, block: () -> T): T? {
|
||||
/** [block]'s result, or [ifBusy]'s while another operation has the installation. */
|
||||
private inline fun <T> exclusive(what: String, ifBusy: () -> T, block: () -> T): T {
|
||||
synchronized(this) {
|
||||
if (busy != null) return null
|
||||
if (busy != null) return ifBusy()
|
||||
busy = what
|
||||
}
|
||||
try { return block() } finally { busy = null }
|
||||
@@ -104,7 +105,7 @@ object FlatpakManager {
|
||||
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
|
||||
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
return exclusive("setup") {
|
||||
return exclusive("setup", { context.getString(R.string.flatpakmgr_busy) }) {
|
||||
var failure: String? = null
|
||||
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
|
||||
Log.i(TAG, "setup: $line")
|
||||
@@ -119,16 +120,18 @@ object FlatpakManager {
|
||||
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
|
||||
else -> null
|
||||
}
|
||||
} ?: context.getString(R.string.flatpakmgr_busy)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One store operation - install, uninstall or update - with its progress as a stage line and
|
||||
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
|
||||
*/
|
||||
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun transaction(
|
||||
context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit, onDone: (JSONObject) -> Unit = {},
|
||||
): String? {
|
||||
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
|
||||
return exclusive("$verb:${id ?: "all"}") {
|
||||
return exclusive("$verb:${id ?: "all"}", { context.getString(R.string.flatpakmgr_busy) }) {
|
||||
var error: String? = null
|
||||
// Progress lines carry no ref; they belong to the step the last "op" line started.
|
||||
var step = context.getString(R.string.flatpakmgr_starting)
|
||||
@@ -141,10 +144,11 @@ object FlatpakManager {
|
||||
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
|
||||
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
|
||||
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
|
||||
"done" -> onDone(o)
|
||||
}
|
||||
}
|
||||
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
|
||||
} ?: context.getString(R.string.flatpakmgr_busy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stage(context: Context, o: JSONObject): String {
|
||||
@@ -195,13 +199,20 @@ object FlatpakManager {
|
||||
|
||||
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
|
||||
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
|
||||
|
||||
/** Installs the .flatpak bundle at [guestPath], its runtimes from Flathub: the app's ID, and what went wrong if anything. */
|
||||
fun installBundle(context: Context, guestPath: String, onProgress: (String, Int) -> Unit): Pair<String?, String?> {
|
||||
var id: String? = null
|
||||
val problem = transaction(context, "install-bundle", guestPath, onProgress) { id = it.optString("id").ifEmpty { null } }
|
||||
return id to problem
|
||||
}
|
||||
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
|
||||
|
||||
/** Installed apps with an update on Flathub, or null when that could not be checked. */
|
||||
fun updates(context: Context): Set<String>? {
|
||||
if (!ready(context)) return emptySet()
|
||||
var apps: Set<String>? = null
|
||||
exclusive("updates") {
|
||||
exclusive("updates", {}) {
|
||||
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
|
||||
if (o.optString("e") == "updates") {
|
||||
|
||||
@@ -17,7 +17,7 @@ object GuestCommand {
|
||||
/**
|
||||
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
|
||||
* for the package tools, which refuse any uid but 0. With [logName], everything the command
|
||||
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
|
||||
* says also goes to files/logs/tools/<logName>.log beside the session logs, so a problem can
|
||||
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
|
||||
* become symlinks to files proot keeps there (its link2symlink).
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ object GuestCommand {
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
|
||||
@@ -44,10 +44,20 @@ public final class LinuxRuntime {
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
private static final String SYSTEM_FONTS = "/system/fonts";
|
||||
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
/** The folder under Downloads where builds before private logs wrote them; read only to move them. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
public static File debugLogDir() {
|
||||
/**
|
||||
* Where every log lands: app-private, {@code files/logs}. Session folders, tools/ (one-off
|
||||
* commands) and stores/ sit under it; the files directory is bound into every session at the
|
||||
* same path, so the guest writes there too. Share logs is how a folder leaves the device.
|
||||
*/
|
||||
public static File logDir(Context context) {
|
||||
return new File(context.getFilesDir(), "logs");
|
||||
}
|
||||
|
||||
/** {@code Download/DroidDeck}, where earlier builds wrote logs. Only LogMigration reads it. */
|
||||
public static File legacyLogDir() {
|
||||
return new File(android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
|
||||
}
|
||||
|
||||
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user