mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-21 06:34:44 -04:00
Compare commits
46
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 |
No files matched your search
@@ -24,11 +24,30 @@ 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 != ''
|
||||
@@ -50,7 +69,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
|
||||
tools/desktop-kde/build.sh out
|
||||
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
|
||||
@@ -61,6 +81,7 @@ jobs:
|
||||
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 != ''
|
||||
@@ -76,5 +97,5 @@ jobs:
|
||||
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.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
+54
-76
@@ -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
|
||||
@@ -272,11 +288,38 @@ jobs:
|
||||
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
|
||||
@@ -340,8 +383,9 @@ jobs:
|
||||
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-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: Cache proot
|
||||
id: proot
|
||||
@@ -361,42 +405,7 @@ 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 7z 7z.so 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
|
||||
@@ -445,10 +454,13 @@ 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
|
||||
@@ -457,21 +469,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
NM="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
|
||||
SO=app/build/rustJniLibs/arm64-v8a/libdroiddeckstores.so
|
||||
test -s "$SO"
|
||||
ls -la "$SO"
|
||||
unzip -l app/build/outputs/apk/release/app-release.apk | grep -q 'lib/arm64-v8a/libdroiddeckstores.so' \
|
||||
|| { echo "::error::libdroiddeckstores.so is not in the apk"; exit 1; }
|
||||
for sym in \
|
||||
Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion \
|
||||
Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart \
|
||||
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart \
|
||||
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble \
|
||||
Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart; do
|
||||
"$NM" -D --defined-only "$SO" | grep -q " ${sym}\$" \
|
||||
|| { echo "::error::libdroiddeckstores.so does not export $sym"; exit 1; }
|
||||
done
|
||||
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; }
|
||||
@@ -502,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,6 +13,7 @@ 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
|
||||
|
||||
@@ -25,7 +25,11 @@ Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview bu
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+48
-3
@@ -65,6 +65,13 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def 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('#') }
|
||||
@@ -94,6 +101,7 @@ 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}\""
|
||||
@@ -198,6 +206,32 @@ 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
|
||||
@@ -214,6 +248,11 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
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
|
||||
@@ -223,12 +262,18 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
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.
|
||||
commandLine 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
|
||||
// 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'
|
||||
'build', '--release', '--locked'
|
||||
}
|
||||
android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
// 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 {
|
||||
|
||||
@@ -30,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" />
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
@@ -22,8 +22,6 @@ 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)
|
||||
|
||||
@@ -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
|
||||
@@ -119,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)
|
||||
@@ -139,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)
|
||||
@@ -161,6 +163,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
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)
|
||||
@@ -301,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)
|
||||
@@ -335,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)
|
||||
@@ -357,6 +364,8 @@ 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)
|
||||
@@ -526,6 +535,8 @@ 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)
|
||||
@@ -555,7 +566,7 @@ 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, agentCommands = agentCommands,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
@@ -583,6 +594,7 @@ class MainActivity : ComponentActivity() {
|
||||
updates = updates.state(),
|
||||
phantomProcessStatus = phantomProcessStatus,
|
||||
showPhantomGate = showPhantomGate,
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
@@ -590,23 +602,46 @@ class MainActivity : ComponentActivity() {
|
||||
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)
|
||||
},
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
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)
|
||||
@@ -649,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) },
|
||||
@@ -713,6 +749,11 @@ 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
|
||||
@@ -802,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 },
|
||||
@@ -871,6 +913,7 @@ class MainActivity : ComponentActivity() {
|
||||
// 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)
|
||||
@@ -912,6 +955,15 @@ 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. */
|
||||
@@ -938,6 +990,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
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.
|
||||
@@ -1178,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) {
|
||||
@@ -1231,6 +1286,8 @@ 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,
|
||||
@@ -1242,8 +1299,6 @@ class MainActivity : ComponentActivity() {
|
||||
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,
|
||||
@@ -1253,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,
|
||||
@@ -1291,6 +1343,7 @@ 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)
|
||||
@@ -1323,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 },
|
||||
@@ -1398,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() {
|
||||
@@ -1429,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
|
||||
@@ -1442,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)
|
||||
@@ -1470,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
|
||||
@@ -1505,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)
|
||||
@@ -1524,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)
|
||||
@@ -1544,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
|
||||
@@ -1637,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
|
||||
@@ -1659,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
|
||||
@@ -51,6 +52,7 @@ 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
|
||||
@@ -109,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
|
||||
@@ -119,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
|
||||
@@ -132,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"
|
||||
@@ -142,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 {
|
||||
@@ -232,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)
|
||||
@@ -290,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()
|
||||
}
|
||||
@@ -305,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()
|
||||
@@ -359,13 +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
|
||||
@@ -557,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
|
||||
@@ -599,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() },
|
||||
@@ -779,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)
|
||||
@@ -794,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 ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -1103,6 +1161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
applyFollowOrientation(newConfig)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
@@ -1330,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
|
||||
@@ -1382,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)
|
||||
@@ -1425,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 &&
|
||||
@@ -1436,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)
|
||||
@@ -1660,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
|
||||
@@ -1687,6 +1736,64 @@ 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)
|
||||
@@ -1707,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"
|
||||
@@ -1730,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) {
|
||||
@@ -1849,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
|
||||
@@ -1946,6 +2144,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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?) {
|
||||
@@ -2002,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()
|
||||
@@ -2096,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.
|
||||
@@ -2107,6 +2308,7 @@ 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -182,26 +182,30 @@ public class PulseAudioComponent extends SessionPart {
|
||||
* client is never left without an output. Says so in the audio log either way.
|
||||
*/
|
||||
private void ensureClientSink(java.io.PrintWriter out) {
|
||||
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; }
|
||||
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);
|
||||
}
|
||||
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(); }
|
||||
}
|
||||
|
||||
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
|
||||
@@ -223,8 +227,14 @@ public class PulseAudioComponent extends SessionPart {
|
||||
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
|
||||
process = builder.start();
|
||||
String output;
|
||||
try (InputStream in = process.getInputStream()) {
|
||||
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
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();
|
||||
@@ -234,7 +244,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
|
||||
@@ -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)}%)")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -93,9 +93,12 @@ object LogRedactor {
|
||||
"(?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
|
||||
@@ -103,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) {
|
||||
@@ -125,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
|
||||
@@ -133,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
|
||||
@@ -142,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) {
|
||||
@@ -263,41 +270,87 @@ object LogRedactor {
|
||||
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 = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
|
||||
out = MAC.replace(out, "<redacted:mac>")
|
||||
out = DEVICE_IDENTIFIER.replace(out) {
|
||||
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>" })
|
||||
}
|
||||
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>")
|
||||
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>" }
|
||||
out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
|
||||
out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -33,26 +33,27 @@ object AddedGames {
|
||||
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(
|
||||
@@ -86,47 +87,286 @@ object AddedGames {
|
||||
// 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
|
||||
@@ -145,13 +385,16 @@ 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 {
|
||||
// A store install carries a sidecar; every one of them is a shortcut.
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
val candidates = candidates(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 }
|
||||
?: 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
|
||||
@@ -159,23 +402,32 @@ object AddedGames {
|
||||
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 }
|
||||
val name = sidecar?.title ?: folder.name
|
||||
// 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, (if (adopted != null) adopted else launch).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,
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,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() }
|
||||
@@ -218,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,21 +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" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
}).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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -20,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(
|
||||
@@ -95,12 +95,26 @@ object DesktopCatalog {
|
||||
|
||||
// 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. The launcher is staged by
|
||||
// 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, "usr/bin/kwin_wayland").isFile && File(root, ".droiddeck-pkg-$DESKTOP_ID").isFile
|
||||
!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? {
|
||||
@@ -141,7 +155,13 @@ object DesktopCatalog {
|
||||
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)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
/** Catalog row, beside the other component catalogs in winlator-contents. */
|
||||
public static final String CATALOG_URL =
|
||||
"https://raw.githubusercontent.com/The412Banner/winlator-contents/main/linuxfs.json";
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
|
||||
|
||||
private static final String VERSION_FILE = ".version";
|
||||
|
||||
@@ -85,6 +85,7 @@ public final class LinuxRuntimeInstaller {
|
||||
volatile int percent = -1;
|
||||
volatile boolean ok;
|
||||
final boolean removal;
|
||||
boolean maintenance;
|
||||
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ public final class LinuxRuntimeInstaller {
|
||||
Job job;
|
||||
boolean owner;
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null && running.removal) return false;
|
||||
if (running != null && (running.removal || running.maintenance)) return false;
|
||||
owner = running == null;
|
||||
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
|
||||
job = running;
|
||||
@@ -171,7 +172,7 @@ public final class LinuxRuntimeInstaller {
|
||||
/** True while an install is running in this process, whoever started it. */
|
||||
public static boolean isInstalling() {
|
||||
synchronized (JOB_LOCK) {
|
||||
return running != null && !running.removal;
|
||||
return running != null && !running.removal && !running.maintenance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +184,53 @@ public final class LinuxRuntimeInstaller {
|
||||
synchronized (JOB_LOCK) { return running != null && running.removal; }
|
||||
}
|
||||
|
||||
public static boolean isMaintaining() {
|
||||
synchronized (JOB_LOCK) { return running != null && running.maintenance; }
|
||||
}
|
||||
|
||||
/** Reserve an in-place package operation with the same exclusion/progress as runtime installs. */
|
||||
public static Maintenance beginMaintenance(Context context, String stage) {
|
||||
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
|
||||
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
|
||||
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null) return null;
|
||||
removalError = null;
|
||||
running = new Job(false, stage);
|
||||
running.maintenance = true;
|
||||
return new Maintenance(running);
|
||||
}
|
||||
}
|
||||
|
||||
public interface MaintenanceWork { String run(ProgressListener listener); }
|
||||
|
||||
public static final class Maintenance {
|
||||
private final Job job;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private Maintenance(Job job) { this.job = job; }
|
||||
public boolean run(ProgressListener listener, MaintenanceWork work) {
|
||||
if (!started.compareAndSet(false, true)) return join(job, listener);
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
try {
|
||||
removalError = work.run((stage, percent) -> {
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
|
||||
});
|
||||
job.ok = removalError == null;
|
||||
return job.ok;
|
||||
} catch (Exception e) {
|
||||
removalError = e.getMessage();
|
||||
Log.e(TAG, "maintenance", e);
|
||||
return false;
|
||||
} finally {
|
||||
synchronized (JOB_LOCK) { running = null; }
|
||||
job.done.countDown();
|
||||
if (listener != null) job.listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String removalError() { return removalError; }
|
||||
|
||||
public static boolean hasRemovalPending(Context context) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class RuntimeInstallService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val removal = intent?.action == ACTION_REMOVE
|
||||
val release = intent?.let(::releaseFrom)
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
if (release == null && !removal) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
@@ -68,7 +68,7 @@ class RuntimeInstallService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
|
||||
@@ -32,6 +32,8 @@ object UserApps {
|
||||
private const val TAG = "UserApps"
|
||||
const val GUEST_DIR = "/opt/droiddeck-apps"
|
||||
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
|
||||
/** An app the desktop asked for (droiddeck-open): its key, then the path it was started from there. */
|
||||
const val DESKTOP_IMPORT = "desktop-import"
|
||||
private const val FLATPAK_OVERRIDES = ".flatpak"
|
||||
private const val ICON_SIZE = 256
|
||||
private const val ELF_SCAN_LIMIT = 4000
|
||||
@@ -72,8 +74,8 @@ object UserApps {
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?)
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. [meta]: files written into the added app's folder. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?, val meta: Map<String, String> = emptyMap())
|
||||
|
||||
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
|
||||
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
|
||||
@@ -121,8 +123,8 @@ object UserApps {
|
||||
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return withIcon(context, request.icon) { icon ->
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
@@ -197,6 +199,14 @@ object UserApps {
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
/** The desktop's import of [key] as the session sees it: an added script's folder, or an AppImage's. */
|
||||
fun desktopImport(context: Context, key: String, path: String): String? {
|
||||
root(context).listFiles()?.firstOrNull { dir ->
|
||||
File(dir, "entry").isFile && FileUtils.readString(File(dir, DESKTOP_IMPORT))?.lineSequence()?.firstOrNull() == key
|
||||
}?.let { return "$GUEST_DIR/${it.name}" }
|
||||
return AppImageManager.desktopImport(context, key, path)?.let { "${AppImageManager.GUEST_DIR}/$it" }
|
||||
}
|
||||
|
||||
fun planScript(script: File): ScriptPlan {
|
||||
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
|
||||
var scanned = 0
|
||||
@@ -209,7 +219,9 @@ object UserApps {
|
||||
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun addScript(
|
||||
context: Context, script: File, name: String?, icon: File?, meta: Map<String, String>, onProgress: (String, Int) -> Unit,
|
||||
): String? {
|
||||
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
|
||||
val plan = planScript(script)
|
||||
val base = root(context).apply { mkdirs() }
|
||||
@@ -232,6 +244,7 @@ object UserApps {
|
||||
FileUtils.writeString(File(dir, "name"), title)
|
||||
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
|
||||
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
|
||||
writeScriptEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -68,12 +68,19 @@ object GameStorage {
|
||||
|
||||
class Library(val host: File, val guest: String, val label: String)
|
||||
|
||||
/**
|
||||
* Games folders an earlier build registered as Steam libraries that Steam has installed games
|
||||
* into (an appmanifest in their steamapps/): those stay libraries, so the games are not lost.
|
||||
* Any other Games folder is no longer one; the session's library list leaves it out and the
|
||||
* runtime's droiddeck-steam-library forgets it.
|
||||
*/
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
if (!hasInstalledGames(root.host)) return@mapNotNull null
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
|
||||
@@ -83,6 +90,10 @@ object GameStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam has installed a game into [root] as a library: a manifest in its steamapps/. */
|
||||
internal fun hasInstalledGames(root: File): Boolean =
|
||||
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
/** The label the client shows for a chosen folder: its last name, or the volume's. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.SystemClock
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
@@ -22,19 +23,21 @@ import kotlin.math.max
|
||||
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
|
||||
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
|
||||
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
|
||||
* values. This listens for those and plays the effect on the physical controller that last drove
|
||||
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
|
||||
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
|
||||
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
|
||||
* missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
* values. This listens for those and plays the effect on the physical controller playing in that
|
||||
* slot (PadBridge.controllerForSlot) when Android exposes its motors - strong and weak separately
|
||||
* where it has two - and otherwise on the device's own vibrator, with the stronger motor's
|
||||
* strength: the on-screen pad, or a controller without rumble. Best effort both ways: a missing
|
||||
* listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var phone: Motors? = null
|
||||
/** Where the last effect went, so the next one (or a stop) can end it there. */
|
||||
private var playing: Motors? = null
|
||||
private var controllerId = PadBridge.NO_CONTROLLER
|
||||
private var controller: Motors? = null
|
||||
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
|
||||
private val playing = HashMap<Int, Motors>()
|
||||
/** Per slot, the last effect handed to the motors and when, to drop the repeats games stream. */
|
||||
private val sent = HashMap<Int, Sent>()
|
||||
/** Each controller's motors, looked up once; null when it has none. */
|
||||
private val controllers = HashMap<Int, Motors?>()
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
@@ -46,7 +49,7 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
if (!enabled) cancelAll()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
@@ -64,7 +67,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4), u16(bytes, 6))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -79,11 +82,9 @@ class RumbleComponent : SessionPart() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
cancelAll()
|
||||
phone = null
|
||||
controller = null
|
||||
controllerId = PadBridge.NO_CONTROLLER
|
||||
controllers.clear()
|
||||
val s = server
|
||||
server = null
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
@@ -91,33 +92,48 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) {
|
||||
if (!enabled) return
|
||||
val target = target()
|
||||
if (target !== playing) {
|
||||
playing?.cancel()
|
||||
playing = target
|
||||
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
|
||||
val target = target(slot)
|
||||
val previous = playing[slot]
|
||||
if (target !== previous) {
|
||||
previous?.cancel()
|
||||
sent.remove(slot)
|
||||
if (target == null) playing.remove(slot) else playing[slot] = target
|
||||
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
|
||||
// Games re-send their effect every frame; restarting the motor that often floods the haptics
|
||||
// driver. ponytail: a change inside MIN_GAP_MS is dropped, the game's next packet brings it.
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val last = sent[slot]
|
||||
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
target.play(strong, weak, duration)
|
||||
sent[slot] = Sent(strong, weak, now, now + duration)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The active controller's motors when it has any, else the phone's. */
|
||||
private fun target(): Motors? {
|
||||
val id = PadBridge.activeControllerId()
|
||||
if (id != controllerId) {
|
||||
controllerId = id
|
||||
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
}
|
||||
return controller ?: phone
|
||||
/** The motors of the controller playing in this slot when it has any, else the phone's. */
|
||||
private fun target(slot: Int): Motors? {
|
||||
val id = PadBridge.controllerForSlot(slot)
|
||||
if (id == PadBridge.NO_CONTROLLER) return phone
|
||||
if (!controllers.containsKey(id)) controllers[id] = try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
return controllers[id] ?: phone
|
||||
}
|
||||
|
||||
private fun cancelAll() {
|
||||
playing.values.toSet().forEach { it.cancel() }
|
||||
playing.clear()
|
||||
sent.clear()
|
||||
}
|
||||
|
||||
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
@@ -158,6 +174,8 @@ class RumbleComponent : SessionPart() {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
/** The shortest time between two effects started on one slot's motors. */
|
||||
const val MIN_GAP_MS = 50L
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
|
||||
@@ -52,6 +52,26 @@ object SessionDisplay {
|
||||
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
|
||||
if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv())
|
||||
|
||||
/** Unfolded controls: the most of the window the game may take, so a window near 16:9 itself
|
||||
* still leaves the Deck controls room. */
|
||||
const val UNFOLDED_MAX_GAME_SHARE = 0.8f
|
||||
|
||||
/** A foldable: Android 11's hinge angle sensor, which foldables report and slates do not. Unfolded
|
||||
* controls are offered only there - untested on tablets, whose 16:10 landscape leaves them
|
||||
* little room under a 16:9 game. */
|
||||
fun foldable(context: Context): Boolean =
|
||||
context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")
|
||||
|
||||
/** A panel at least this wide on its shortest side (dp) is a foldable's unfolded inner screen. */
|
||||
const val UNFOLDED_MIN_DP = 600
|
||||
|
||||
/** Unfolded controls: the game's height in a window of this size - 16:9 across the full width,
|
||||
* held either way, with even sides - and the Deck controls take the rest below it, laid out to
|
||||
* whatever that leaves (DeckControlsPanel's GamepadLayout). */
|
||||
fun unfoldedGameHeight(width: Int, height: Int): Int =
|
||||
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
|
||||
|
||||
|
||||
fun panelSize(context: Context): Pair<Int, Int> {
|
||||
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -20,6 +20,19 @@ object SessionFiles {
|
||||
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
|
||||
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
|
||||
|
||||
// Staged only with a complete desktop; removal matches these against the APK as well.
|
||||
internal val DESKTOP_FILES = arrayOf(
|
||||
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
|
||||
// Games and emulators from the menu, full screen in a gamescope of their own.
|
||||
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
|
||||
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
|
||||
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
|
||||
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
|
||||
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
|
||||
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
|
||||
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
|
||||
)
|
||||
|
||||
/**
|
||||
* The libraries and scripts the session runs, refreshed from the apk at every launch.
|
||||
*
|
||||
@@ -30,6 +43,8 @@ object SessionFiles {
|
||||
* rename, so a session that still has one mapped keeps the file it opened.
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
|
||||
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
|
||||
GameEnvironmentStore.publish(context)
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
@@ -42,15 +57,19 @@ object SessionFiles {
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
|
||||
"libblaudit.so" to "usr/local/lib/libblaudit.so",
|
||||
"libssbs.so" to "usr/local/lib/libssbs.so",
|
||||
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
|
||||
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
|
||||
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
|
||||
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
|
||||
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
|
||||
"usr/local/bin/droiddeck-desktop-clipboard" to "usr/local/bin/droiddeck-desktop-clipboard",
|
||||
"usr/local/bin/droiddeck-gpu-window" to "usr/local/bin/droiddeck-gpu-window",
|
||||
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
|
||||
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
|
||||
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
|
||||
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
|
||||
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
@@ -75,6 +94,10 @@ object SessionFiles {
|
||||
// The user's own AppImages, extracted at import (AppImageManager).
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
// The desktop's Run entries and its exec fallback: Windows programs through Steam's Proton,
|
||||
// x86 Linux programs through FEX.
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
@@ -91,17 +114,6 @@ object SessionFiles {
|
||||
"usr/bin/steamos-polkit-helpers/jupiter-biosupdate" to "usr/bin/steamos-polkit-helpers/jupiter-biosupdate",
|
||||
"usr/bin/steamos-polkit-helpers/jupiter-dock-updater" to "usr/bin/steamos-polkit-helpers/jupiter-dock-updater",
|
||||
)
|
||||
// The desktop's launchers, only where the desktop package is installed:
|
||||
// staging them into a runtime without it would make the desktop look present when it is not.
|
||||
val desktop = arrayOf(
|
||||
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
|
||||
// Games and emulators from the menu, full screen in a gamescope of their own.
|
||||
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
|
||||
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
|
||||
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
|
||||
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
|
||||
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
|
||||
)
|
||||
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
|
||||
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
|
||||
// apk carries it - a build without the asset leaves the runtime's copy alone.
|
||||
@@ -121,8 +133,8 @@ object SessionFiles {
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
|
||||
listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
val optional = (arrayOf(
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
@@ -131,7 +143,7 @@ object SessionFiles {
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
val all = (if (DesktopCatalog.desktopInstalled(root)) files + desktop else files) + optional
|
||||
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
|
||||
for ((asset, relative) in all) {
|
||||
val target = File(root, relative)
|
||||
val staged = File(target.parentFile, target.name + ".staged")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
@@ -18,10 +19,15 @@ object SessionPrefs {
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
const val CONTROLLER_STEAM = "steamcontroller"
|
||||
const val CONTROLLER_DS4 = "dualshock4"
|
||||
const val CONTROLLER_DUALSENSE = "dualsense"
|
||||
const val OSC_AUTO = "auto"
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
const val OSC_NEVER = "never"
|
||||
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
|
||||
const val OSC_STEAM_TOUCH = "steam-touch"
|
||||
|
||||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||||
@@ -33,6 +39,22 @@ object SessionPrefs {
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
|
||||
val orientationOptions = listOf(
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
|
||||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
|
||||
)
|
||||
|
||||
fun orientation(context: Context): Int =
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
.takeIf { value -> orientationOptions.any { it.first == value } }
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
|
||||
fun setOrientation(context: Context, orientation: Int) {
|
||||
require(orientationOptions.any { it.first == orientation })
|
||||
prefs(context).edit().putInt("orientation", orientation).apply()
|
||||
}
|
||||
|
||||
/** Whether the launcher hides Android's status and navigation bars. */
|
||||
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
|
||||
|
||||
@@ -112,9 +134,9 @@ object SessionPrefs {
|
||||
|
||||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||||
|
||||
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
|
||||
/** A Steam session with Deck mode's performance overlay (mangoapp). */
|
||||
private fun mangoappSession(context: Context) =
|
||||
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
|
||||
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
|
||||
|
||||
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
|
||||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||||
@@ -430,27 +452,21 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
|
||||
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
|
||||
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
|
||||
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
|
||||
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
*/
|
||||
fun steamController(context: Context): String =
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On by default (settleDeckModeDefault). */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/**
|
||||
* Deck mode is the default: every install, new or from before, is moved to it once. An install
|
||||
* from before 0.3.0 had off written down for it whether or not anyone chose it, so the move
|
||||
* can't tell a choice from that default and moves everyone; turning it off afterwards sticks.
|
||||
* Run once at process start, before anything reads or writes these prefs.
|
||||
*/
|
||||
fun settleDeckModeDefault(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.getBoolean("deckModeMoved", false)) return
|
||||
p.edit().putBoolean("steamDeckMode", true).putBoolean("deckModeMoved", true).apply()
|
||||
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
|
||||
fun padType(id: String): Int = when (id) {
|
||||
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
|
||||
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
|
||||
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
|
||||
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
|
||||
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,24 +600,6 @@ object SessionPrefs {
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam client branch used for the first download and forced on the command line:
|
||||
* "steamdeck_publicbeta" by default, or "publicbeta" when chosen with Deck mode off.
|
||||
* Deck mode always takes the Deck branch, whatever was chosen:
|
||||
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
|
||||
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
|
||||
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
|
||||
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
|
||||
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
|
||||
*/
|
||||
fun steamChannel(context: Context): String =
|
||||
if (steamDeckMode(context)) "steamdeck_publicbeta"
|
||||
else prefs(context).getString("steamChannel", null) ?: "steamdeck_publicbeta"
|
||||
|
||||
fun setSteamChannel(context: Context, id: String) {
|
||||
prefs(context).edit().putString("steamChannel", id).apply()
|
||||
}
|
||||
|
||||
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
|
||||
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
|
||||
|
||||
@@ -610,8 +608,9 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders of the user's own Windows games (one game per subfolder), any number of them
|
||||
* from anywhere on the device. The single folder an earlier build kept is carried in.
|
||||
* The Games folders earlier builds let the user add (one game per subfolder); read only by
|
||||
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
|
||||
* even earlier build kept is carried in.
|
||||
*/
|
||||
fun addedGamesDirs(context: Context): List<String> {
|
||||
val p = prefs(context)
|
||||
@@ -625,6 +624,80 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games folders earlier builds had (host path, guest path), still bound into the session so
|
||||
* their games keep their paths; written once by GamesFolderMigration.
|
||||
*/
|
||||
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
|
||||
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
|
||||
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
|
||||
}
|
||||
|
||||
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
|
||||
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
|
||||
}
|
||||
|
||||
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
|
||||
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
|
||||
|
||||
fun setGamesFoldersMigrated(context: Context) {
|
||||
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Game folders (by path) the user removed from the added games: a scan skips them, so they
|
||||
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
|
||||
*/
|
||||
fun removedAddedGames(context: Context): List<String> =
|
||||
(prefs(context).getString("removedAddedGames", "") ?: "").split('\n').filter { it.isNotEmpty() }
|
||||
|
||||
fun setAddedGameRemoved(context: Context, folderPath: String, removed: Boolean) {
|
||||
synchronized(addedGameLock) {
|
||||
val now = removedAddedGames(context).filter { it != folderPath } + listOfNotNull(folderPath.takeIf { removed })
|
||||
prefs(context).edit().putString("removedAddedGames", now.joinToString("\n")).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** An added game's name as the user set it (in the app, or in Steam's Properties); "" = automatic. */
|
||||
fun addedGameName(context: Context, folderPath: String): String = prefs(context).getString("addedName:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameName(context: Context, folderPath: String, name: String) {
|
||||
prefs(context).edit().putString("addedName:$folderPath", name.trim()).apply()
|
||||
}
|
||||
|
||||
/** An added game's Start in, as the session sees it; "" = the exe's own folder. */
|
||||
fun addedGameStartIn(context: Context, folderPath: String): String = prefs(context).getString("addedStartIn:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameStartIn(context: Context, folderPath: String, guestDir: String) {
|
||||
prefs(context).edit().putString("addedStartIn:$folderPath", guestDir.trim()).apply()
|
||||
}
|
||||
|
||||
/** An added game's launch options, as Steam takes them ("-dx11", "VAR=1 %command%"). */
|
||||
fun addedGameLaunch(context: Context, folderPath: String): String = prefs(context).getString("addedLaunch:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameLaunch(context: Context, folderPath: String, options: String) {
|
||||
prefs(context).edit().putString("addedLaunch:$folderPath", options.trim()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The last value of [field] (AppName, StartDir, LaunchOptions) taken over from Steam's own
|
||||
* Properties for this game, so the same Steam edit is not taken again over a later app edit.
|
||||
*/
|
||||
fun addedGameAdopted(context: Context, folderPath: String, field: String): String? =
|
||||
prefs(context).getString("addedAdopted:$field:$folderPath", null)
|
||||
|
||||
fun setAddedGameAdopted(context: Context, folderPath: String, field: String, value: String) {
|
||||
prefs(context).edit().putString("addedAdopted:$field:$folderPath", value).apply()
|
||||
}
|
||||
|
||||
/** Where an added game's art for [slot] comes from when the user chose it ("folder", "steam", "sgdb", "file"); "" = automatic. */
|
||||
fun addedGameArtSource(context: Context, folderPath: String, slot: String): String =
|
||||
prefs(context).getString("addedArt:$slot:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameArtSource(context: Context, folderPath: String, slot: String, source: String) {
|
||||
prefs(context).edit().putString("addedArt:$slot:$folderPath", source).apply()
|
||||
}
|
||||
|
||||
/** Whether added games without art of their own get Steam's store art fetched for them. */
|
||||
fun addedGamesArt(context: Context): Boolean = prefs(context).getBoolean("addedGamesArt", true)
|
||||
|
||||
@@ -650,6 +723,10 @@ object SessionPrefs {
|
||||
unchanged
|
||||
}
|
||||
|
||||
/** The shortcut appid stored for the folder, or null before its first scan. */
|
||||
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
|
||||
prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
|
||||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
@@ -823,6 +900,26 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
|
||||
}
|
||||
|
||||
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
|
||||
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
|
||||
|
||||
fun setUnfoldedControls(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
|
||||
}
|
||||
|
||||
const val SURFACE_STICK = "stick"
|
||||
const val SURFACE_TRACKPAD = "trackpad"
|
||||
|
||||
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
|
||||
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
|
||||
fun gamepadSurface(context: Context, right: Boolean): String =
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
}
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.droiddeck.launcher.audio.PulseAudioComponent
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.DeviceReport
|
||||
import com.droiddeck.launcher.core.HostEnvironment
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.SessionLogCapture
|
||||
import com.droiddeck.launcher.core.NetworkReport
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
@@ -36,9 +37,12 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -205,6 +209,10 @@ class SessionService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
if (SessionState.running) return START_NOT_STICKY
|
||||
if (SessionState.stopRequested) {
|
||||
@@ -402,6 +410,8 @@ class SessionService : Service() {
|
||||
guest.add(it)
|
||||
Log.i(TAG, "steam: handing the client $it")
|
||||
}
|
||||
// A game whose download finishes in this session gets its Windows components readied in it.
|
||||
if (SessionState.mode == MODE_STEAM) com.droiddeck.launcher.frontend.SessionInstalls.watch(this)
|
||||
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
|
||||
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
|
||||
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
|
||||
@@ -615,8 +625,7 @@ class SessionService : Service() {
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
}
|
||||
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
|
||||
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
// thread and no GL error checks.
|
||||
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
|
||||
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
|
||||
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
|
||||
@@ -629,9 +638,7 @@ class SessionService : Service() {
|
||||
// every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap
|
||||
// 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too.
|
||||
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
|
||||
}
|
||||
@@ -675,7 +682,9 @@ class SessionService : Service() {
|
||||
// changes what games do and leaves the client alone. The microphone is its own opt-in on
|
||||
// top, and the helper only opens an input stream when asked - so a user who wants game
|
||||
// sound but no recording gets exactly that, and Android's recording indicator stays off.
|
||||
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
|
||||
// The desktop starts Windows programs through the same Proton wrappers (droiddeck-proton-run).
|
||||
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) &&
|
||||
SessionPrefs.directAudio(this)
|
||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
// Both paths sit under the app's files directory, which the session binds at its own path,
|
||||
@@ -733,10 +742,18 @@ class SessionService : Service() {
|
||||
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
|
||||
// Decided only once its sysfs is in place: without it the client would find no Deck, and
|
||||
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
|
||||
// Set up whatever the pad is to be, so the drawer can switch it between a Deck, a Steam
|
||||
// Controller and an Xbox 360 pad while the session runs (FakeInputWriter.setPadType).
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
SessionState.steamUi != "desktop" &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
// A paired Triton is exposed as a second native Steam Controller HID, with its original
|
||||
// report IDs and output path, beside whatever the handheld's own pad is.
|
||||
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
|
||||
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
@@ -746,6 +763,15 @@ class SessionService : Service() {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
|
||||
// What the pad starts as; the drawer's changes reach libfakeinput through the ring.
|
||||
val padType = SessionPrefs.steamController(this)
|
||||
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
|
||||
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
}
|
||||
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
|
||||
}
|
||||
} else if (SessionState.mode == MODE_STEAM) {
|
||||
@@ -1202,6 +1228,13 @@ class SessionService : Service() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
|
||||
val request = File(dir, path)
|
||||
val text = runCatching { request.readText() }.getOrNull() ?: return
|
||||
request.delete()
|
||||
importForDesktop(dir, text)
|
||||
return
|
||||
}
|
||||
if (path != "steam-launch") return
|
||||
val file = File(dir, path)
|
||||
val text = try { file.readText() } catch (e: Exception) { return }
|
||||
@@ -1231,6 +1264,48 @@ class SessionService : Service() {
|
||||
launchWatcher = watcher
|
||||
}
|
||||
|
||||
private val desktopImports = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
/**
|
||||
* What the desktop cannot run where it lies (droiddeck-open), added as Add an app adds it, menu entry
|
||||
* included: an AppImage of sharun's, whose hard links Android denies apps and only the app's own proot
|
||||
* (link2symlink) makes, or a script or program whose folder of Linux programs is on noexec shared
|
||||
* storage, copied in. The answer goes beside the request, "ok <folder>" or "error <why>".
|
||||
*/
|
||||
private fun importForDesktop(dir: File, text: String) {
|
||||
val fields = text.lineSequence().mapNotNull { line ->
|
||||
line.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].trim() }
|
||||
}.toMap()
|
||||
val key = fields["id"]?.takeIf { it.matches(Regex("[0-9a-f]{16}")) } ?: return
|
||||
val guestPath = fields["path"]?.takeIf { it.startsWith("/") && !it.contains("/../") } ?: return
|
||||
val folder = fields["kind"] == "folder"
|
||||
val binds = LinuxRuntime.lastBinds(this).map { BwrapSpawner.Bind.parse(it) }
|
||||
val file = File(BwrapSpawner.hostPath(binds, LinuxRuntime.rootDir(this).path, guestPath))
|
||||
val context = applicationContext
|
||||
// A request for what is already being added waits for that, then finds it.
|
||||
val lock = desktopImports.computeIfAbsent(key) { Any() }
|
||||
Thread({
|
||||
fun imported() = UserApps.desktopImport(context, key, guestPath)?.let { "ok $it" }
|
||||
val request = UserApps.Request(
|
||||
if (folder) UserApps.Source.Script(file) else UserApps.Source.AppImage(file),
|
||||
if (folder) file.parentFile?.name else null, null, mapOf(UserApps.DESKTOP_IMPORT to "$key\n$guestPath"),
|
||||
)
|
||||
val answer = synchronized(lock) {
|
||||
try {
|
||||
imported() ?: UserApps.add(context, request) { _, _ -> }?.let { "error $it" }
|
||||
?: imported() ?: "error " + context.getString(R.string.user_apps_failed)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Desktop import of $guestPath", e)
|
||||
"error " + (e.message ?: context.getString(R.string.user_apps_failed))
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Desktop import of $guestPath: $answer")
|
||||
val result = File(dir, "desktop-import-$key.result")
|
||||
val partial = File(dir, result.name + ".tmp")
|
||||
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
|
||||
}, "desktop-import").start()
|
||||
}
|
||||
|
||||
@Volatile private var syncWatcher: android.os.FileObserver? = null
|
||||
|
||||
private fun watchSyncWanted(root: File) {
|
||||
@@ -1501,6 +1576,7 @@ class SessionService : Service() {
|
||||
}
|
||||
components.clear()
|
||||
FakeInputWriter.releaseAllRingSlots()
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.release()
|
||||
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
val finishAfterTeardown: () -> Unit = {
|
||||
if (prootPid > 1 || auxiliary.isNotEmpty()) {
|
||||
@@ -1563,8 +1639,10 @@ class SessionService : Service() {
|
||||
}
|
||||
try {
|
||||
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
val allowed = DeviceSupport.lowLatencyWifiSafe()
|
||||
if (!allowed) Log.i(TAG, "wifi lock skipped: its low-latency mode resets this device")
|
||||
@Suppress("DEPRECATION") // deprecated from API 29, still honoured; targetSdk is 28
|
||||
if (wifiLock?.isHeld != true) {
|
||||
if (allowed && wifiLock?.isHeld != true) {
|
||||
wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "DroidDeck:session-wifi")
|
||||
?.apply {
|
||||
setReferenceCounted(false)
|
||||
@@ -1704,7 +1782,7 @@ class SessionService : Service() {
|
||||
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
|
||||
}
|
||||
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -1761,6 +1839,7 @@ class SessionService : Service() {
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
private const val NO_STEAM_TOUCH_SWITCH = "Download/droiddeck-no-steam-touch"
|
||||
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
|
||||
private const val FIRST_VIRTUAL_PAD = 16
|
||||
|
||||
|
||||
@@ -44,6 +44,54 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
|
||||
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
|
||||
private const val TRITON_MINOR = 18
|
||||
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
|
||||
private const val TRITON_SERIAL = "DROIDDECKSC01"
|
||||
// The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the wired
|
||||
// controller (28de:1302) on a USB device of its own, since it is built in.
|
||||
private const val TRITON_USB = "$GUEST_DEVICES/usb3"
|
||||
private const val TRITON_INTERFACE = "$TRITON_USB/3-1:1.0"
|
||||
private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004"
|
||||
/** Must match libfakeinput (DS4_HIDRAW_MINOR, DUALSENSE_HIDRAW_MINOR). */
|
||||
private const val SONY_DS4_MINOR = 20
|
||||
private const val SONY_DUALSENSE_MINOR = 21
|
||||
/** Must match libfakeinput's kDs4ReportDescriptor and kDualSenseReportDescriptor. */
|
||||
private val DS4_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02,
|
||||
0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xb1, 0x02, 0x85, 0x12, 0x09, 0x25, 0x95, 0x0f, 0xb1, 0x02,
|
||||
0x85, 0xa3, 0x09, 0x26, 0x95, 0x30, 0xb1, 0x02, 0x85, 0x81, 0x09, 0x27, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
private val DUALSENSE_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x02, 0x09, 0x21, 0x95, 0x2f, 0x91, 0x02,
|
||||
0x85, 0x05, 0x09, 0x22, 0x95, 0x28, 0xb1, 0x02, 0x85, 0x09, 0x09, 0x23, 0x95, 0x13, 0xb1, 0x02,
|
||||
0x85, 0x20, 0x09, 0x24, 0x95, 0x3f, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
/** Must match libfakeinput (TRITON_PAD_HIDRAW_*): its own node, so a paired controller keeps 18. */
|
||||
private const val TRITON_PAD_MINOR = 19
|
||||
|
||||
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
|
||||
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
private const val TOUCH_NODE = "hidraw$TOUCH_MINOR"
|
||||
private const val TOUCH_USB = "$GUEST_DEVICES/usb2"
|
||||
private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0"
|
||||
private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002"
|
||||
private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE"
|
||||
private const val TOUCH_SERIAL = "DROIDDECK0001"
|
||||
|
||||
/** libfakeinput's kTouchReportDescriptor: Generic Desktop / Game Pad, 40-byte input report. */
|
||||
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
|
||||
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
|
||||
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
|
||||
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
|
||||
private val REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
|
||||
@@ -51,11 +99,65 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
|
||||
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
|
||||
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
|
||||
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** The wired controller's: its 0x42 state report in place of the Bluetooth 0x45. */
|
||||
private val TRITON_WIRED_REPORT_DESCRIPTOR = TRITON_REPORT_DESCRIPTOR.copyOf().also { it[15] = 0x42 }
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File): List<String> {
|
||||
/** The pad's own devices by minor (the Deck, its Steam Controller, the PlayStation pads): guest paths. */
|
||||
private val padNodes = mutableMapOf<Int, String>()
|
||||
@Volatile private var hidrawClassDir: File? = null
|
||||
|
||||
/**
|
||||
* Lists only the device the pad is now ([padType], FakeInputWriter.PAD_TYPE_*) in
|
||||
* /sys/class/hidraw, none for an Xbox 360 pad. Device scans then find what is plugged in: SDL's
|
||||
* (the PlayStation pads' driver) keeps a device it found and could not open, and never tries it
|
||||
* again, while one that disappears from the listing is let go. Before the shim is told.
|
||||
*/
|
||||
@Synchronized
|
||||
fun showPad(padType: Int) {
|
||||
val dir = hidrawClassDir ?: return
|
||||
val shown = when (padType) {
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK -> MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR
|
||||
else -> -1
|
||||
}
|
||||
for ((minor, target) in padNodes) {
|
||||
val link = File(dir, "hidraw$minor")
|
||||
try {
|
||||
if (minor == shown) {
|
||||
if (!link.exists()) { link.delete(); Os.symlink(target, link.path) }
|
||||
} else {
|
||||
link.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "pad: could not list /dev/hidraw$minor: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false,
|
||||
padType: Int = com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
@@ -95,7 +197,8 @@ object SteamDeckPad {
|
||||
link(File(dir(HIDRAW), "device"), HID)
|
||||
|
||||
hidrawClass.mkdirs()
|
||||
link(File(hidrawClass, NODE), HIDRAW)
|
||||
padNodes.clear()
|
||||
padNodes[MINOR] = HIDRAW
|
||||
|
||||
// Only the names matter: libudev reads a listing for the subsystems to descend into and
|
||||
// then opens /sys/class/hidraw itself, which is the bind above.
|
||||
@@ -107,6 +210,116 @@ object SteamDeckPad {
|
||||
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
|
||||
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
|
||||
if (touch) {
|
||||
write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n")
|
||||
write(TOUCH_USB, "idVendor", "0000\n")
|
||||
write(TOUCH_USB, "idProduct", "11fb\n")
|
||||
write(TOUCH_USB, "bcdDevice", "0000\n")
|
||||
write(TOUCH_USB, "manufacturer", "Valve Software\n")
|
||||
write(TOUCH_USB, "product", "Mobile Touch Control\n")
|
||||
write(TOUCH_USB, "serial", "$TOUCH_SERIAL\n")
|
||||
link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
|
||||
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" +
|
||||
"HID_UNIQ=$TOUCH_SERIAL\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
|
||||
File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
|
||||
link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid")
|
||||
write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n")
|
||||
write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n")
|
||||
link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID)
|
||||
link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW)
|
||||
File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
|
||||
}
|
||||
|
||||
// A Steam Controller's HID device: the handheld's own pad as the wired one, or the paired
|
||||
// one on Bluetooth. Both are always described; libfakeinput offers whichever is in use.
|
||||
fun describeTriton(wired: Boolean) {
|
||||
val hid = if (wired) TRITON_WIRED_HID else TRITON_HID
|
||||
val product = if (wired) "1302" else "1303"
|
||||
val bus = if (wired) "0003" else "0005"
|
||||
val minor = if (wired) TRITON_PAD_MINOR else TRITON_MINOR
|
||||
val node = "hidraw$minor"
|
||||
if (wired) {
|
||||
write(TRITON_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1302/100\nTYPE=0/0/0\nBUSNUM=003\nDEVNUM=002\n")
|
||||
write(TRITON_USB, "idVendor", "28de\n")
|
||||
write(TRITON_USB, "idProduct", "1302\n")
|
||||
write(TRITON_USB, "bcdDevice", "0100\n")
|
||||
write(TRITON_USB, "manufacturer", "Valve Software\n")
|
||||
write(TRITON_USB, "product", "Steam Controller\n")
|
||||
write(TRITON_USB, "serial", "$TRITON_SERIAL\n")
|
||||
link(File(dir(TRITON_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TRITON_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1302/100\nINTERFACE=3/0/0\n")
|
||||
write(TRITON_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TRITON_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TRITON_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
}
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=$bus:000028DE:0000$product\n" +
|
||||
"HID_NAME=Valve Software Steam Controller\n" +
|
||||
"HID_PHYS=${if (wired) "usb-droiddeck-3/input0" else "bluetooth-droiddeck-1"}\n" +
|
||||
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b${bus}g0001v000028DEp0000$product\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(if (wired) TRITON_WIRED_REPORT_DESCRIPTOR else TRITON_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
if (wired) padNodes[minor] = hidraw else link(File(hidrawClass, node), hidraw)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)")
|
||||
}
|
||||
// The handheld's own pad as a PlayStation controller, each a USB device of its own with
|
||||
// the report descriptor libfakeinput serves (kDs4/kDualSenseReportDescriptor).
|
||||
fun describeSony(dualSense: Boolean) {
|
||||
val bus = if (dualSense) 5 else 4
|
||||
val product = if (dualSense) "0ce6" else "09cc"
|
||||
val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR
|
||||
val node = "hidraw$minor"
|
||||
val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller"
|
||||
val usb = "$GUEST_DEVICES/usb$bus"
|
||||
val iface = "$usb/$bus-1:1.3"
|
||||
val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}"
|
||||
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=54c/$product/100\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
|
||||
write(usb, "idVendor", "054c\n")
|
||||
write(usb, "idProduct", "$product\n")
|
||||
write(usb, "bcdDevice", "0100\n")
|
||||
write(usb, "manufacturer", "Sony Interactive Entertainment\n")
|
||||
write(usb, "product", "$name\n")
|
||||
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
|
||||
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=54c/$product/100\nINTERFACE=3/0/0\n")
|
||||
write(iface, "bInterfaceNumber", "03\n")
|
||||
write(iface, "bInterfaceClass", "03\n")
|
||||
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" +
|
||||
"HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" +
|
||||
"HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
padNodes[minor] = hidraw
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product")
|
||||
}
|
||||
describeSony(dualSense = false)
|
||||
describeSony(dualSense = true)
|
||||
describeTriton(wired = true)
|
||||
if (triton) describeTriton(wired = false)
|
||||
hidrawClassDir = hidrawClass
|
||||
showPad(padType)
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -38,7 +38,7 @@ import java.util.zip.ZipInputStream
|
||||
*/
|
||||
object WinComponents {
|
||||
private const val TAG = "WinComponents"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/components.json"
|
||||
private const val SELECTION = "wincomponents.json"
|
||||
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
|
||||
private const val STORE = "opt/droiddeck/wincomponents"
|
||||
@@ -59,19 +59,39 @@ object WinComponents {
|
||||
*/
|
||||
private val RUNS_OWN_SETUP = setOf(
|
||||
"K-Lite", "ffdshow", "lavfilters702", "lavfilters741", "quicktime72", "dirac", "webview2", "aairruntime",
|
||||
"gfw", "ie8_kb2936068", "art2kmin", "art2k7min", "vcredist6sp6", "VulkanRT", "jet40", "mdac28", "oalinst",
|
||||
"gfw", "ie8_kb2936068", "art2k7min", "vcredist6sp6", "VulkanRT", "jet40", "mdac28", "oalinst",
|
||||
"dotnet20", "dotnet20sp1", "dotnet35", "dotnet35sp1",
|
||||
)
|
||||
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
|
||||
/** Where a downloaded package or installer waits for the installer, inside the runtime so it can read it. */
|
||||
private const val MSI_CACHE = "$STORE/.packages"
|
||||
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
|
||||
private val PROTON_PROVIDES = setOf("gecko", "mono")
|
||||
/**
|
||||
* Catalog entries every Proton here already ships, which an install would only shadow: Wine
|
||||
* Gecko and Mono, the Chinese/Japanese/Korean fonts (Malgun, MS Gothic, YaHei, SimSun are in
|
||||
* share/fonts of Proton Experimental, GE-Proton and proton-cachyos alike), Wine's own
|
||||
* PowerShell wrapper and the Vulkan loader (vulkan-1.dll, 32- and 64-bit, in all three).
|
||||
*/
|
||||
private val PROTON_PROVIDES = setOf("gecko", "mono", "cjkfonts", "powershell", "VulkanRT")
|
||||
/** The keys the catalog nests under "environment" on a few .NET entries. */
|
||||
private val NESTED_KEYS = setOf("url", "file_name", "file_checksum", "file_size")
|
||||
|
||||
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
|
||||
|
||||
private val BACKGROUND = ThreadLocal<Boolean>()
|
||||
|
||||
/**
|
||||
* Runs [block] as background work: the runtime helpers an install starts on this thread run
|
||||
* at the lowest CPU and I/O priority (nice 19, ionice idle where the runtime has it).
|
||||
*/
|
||||
fun <T> background(block: () -> T): T {
|
||||
BACKGROUND.set(true)
|
||||
try { return block() } finally { BACKGROUND.remove() }
|
||||
}
|
||||
|
||||
private fun guest(argv: List<String>): List<String> = if (BACKGROUND.get() != true) argv else listOf(
|
||||
"/bin/sh", "-c", "if command -v ionice >/dev/null 2>&1; then exec nice -n 19 ionice -c3 \"\$@\"; else exec nice -n 19 \"\$@\"; fi", "sh",
|
||||
) + argv
|
||||
|
||||
class Step(val action: String, val json: JSONObject) {
|
||||
fun str(key: String): String = json.optString(key, "").ifEmpty {
|
||||
if (key in NESTED_KEYS) json.optJSONObject("environment")?.optString(key, "").orEmpty() else ""
|
||||
@@ -81,6 +101,14 @@ object WinComponents {
|
||||
class Component(
|
||||
val name: String, val description: String, val provider: String, val status: String,
|
||||
val dependencies: List<String>, val steps: List<Step>,
|
||||
/**
|
||||
* A recording of what the component's own installer leaves in a prefix (winlator-contents'
|
||||
* component-snapshots release, made by tools/snapshot/record.py): the files it places and
|
||||
* the registry values it writes. With one, the installer never runs: the files come from
|
||||
* the archive beside the recording or out of the vendor's installer, the registry from the
|
||||
* recording. Empty for a component without one.
|
||||
*/
|
||||
val snapshot: String = "",
|
||||
)
|
||||
|
||||
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
|
||||
@@ -105,10 +133,39 @@ object WinComponents {
|
||||
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
|
||||
.any { it.endsWith(".msi", ignoreCase = true) || it.endsWith(".exe", ignoreCase = true) })
|
||||
|
||||
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name.startsWith("mono-")
|
||||
/**
|
||||
* Catalog entries nothing here can use: the IE8 security update patches a browser no prefix
|
||||
* has; the OpenAL setup program is a twin of oalinst_dll, which copies the same DLLs; and
|
||||
* WebView2 can only be installed by running Microsoft's installer in the prefix (its files
|
||||
* sit in a container nothing opens and may not be hosted), which no runtime here does.
|
||||
*/
|
||||
private val POINTLESS = setOf("ie8_kb2936068", "oalinst", "webview2")
|
||||
|
||||
fun fetch(): List<Component>? {
|
||||
val body = Downloader.downloadString(CATALOG_URL) ?: return null
|
||||
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name in POINTLESS || name.startsWith("mono-")
|
||||
|
||||
fun hasSnapshot(c: Component): Boolean = c.snapshot.startsWith("https://github.com/") && c.snapshot.endsWith(".snapshot.json")
|
||||
|
||||
/** The steps a recording makes redundant: everything that places files or edits the registry. */
|
||||
private val SNAPSHOT_COVERS = INSTALLER_STEPS + setOf("download_archive", "archive_extract", "copy_dll", "copy_file") + OFFLINE_STEPS
|
||||
|
||||
fun fetch(): List<Component>? = Downloader.downloadString(CATALOG_URL)?.let { parse(it) }
|
||||
|
||||
private const val CATALOG_CACHE = "wincomponents-catalog.json"
|
||||
|
||||
/** The catalog from the network, kept for later; the copy kept last time when offline. */
|
||||
fun fetch(context: Context): List<Component>? {
|
||||
val cache = File(context.filesDir, CATALOG_CACHE)
|
||||
Downloader.downloadString(CATALOG_URL)?.let { body ->
|
||||
parse(body)?.let { runCatching { write(cache, body) }; return it }
|
||||
}
|
||||
return runCatching { parse(cache.readText()) }.getOrNull()
|
||||
}
|
||||
|
||||
/** The copy of the catalog kept by [fetch], without asking the network; null before the first fetch. */
|
||||
fun cached(context: Context): List<Component>? =
|
||||
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
|
||||
|
||||
private fun parse(body: String): List<Component>? {
|
||||
return try {
|
||||
val arr = JSONObject(body).getJSONArray("components")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
@@ -121,6 +178,7 @@ object WinComponents {
|
||||
name, o.optString("description"), o.optString("provider"), o.optString("status"),
|
||||
(0 until deps.length()).map { deps.getString(it) }.filter { validId(it) },
|
||||
(0 until steps.length()).map { steps.getJSONObject(it).let { s -> Step(s.optString("action"), s) } },
|
||||
o.optString("snapshot"),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -128,10 +186,25 @@ object WinComponents {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog entry that installs [name] here: the detector names installers
|
||||
* (vcredist2022, oalinst), and their DLL-copy twin (vcredist2022_dll, oalinst_dll) is the one
|
||||
* that installs without running anything; [name] itself when there is no such twin.
|
||||
*/
|
||||
fun installable(name: String, all: Map<String, Component>): String {
|
||||
val twin = all[if (name == "oalinst") "oalinst_dll" else "${name}_dll"]
|
||||
return if (twin != null && support(twin, all) == Support.READY) twin.name else name
|
||||
}
|
||||
|
||||
/** Whether [c] can be installed here, its bundled components included. */
|
||||
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
|
||||
if (c.status != "ready" || depth > 8 || protonProvides(c.name)) return Support.UNSUPPORTED
|
||||
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
|
||||
// A recording of the installer's result stands in for running it, whatever the steps say.
|
||||
if (hasSnapshot(c)) {
|
||||
return c.dependencies.map { all[it]?.let { d -> support(d, all, depth + 1) } ?: Support.UNSUPPORTED }
|
||||
.maxOrNull() ?: Support.READY
|
||||
}
|
||||
fun offline(step: Step) = step.action == "delete_dlls" || step.action in OFFLINE_STEPS || step.action in FILE_STEPS && fileStepOk(step)
|
||||
val own = when {
|
||||
installers.isNotEmpty() && c.name !in RUNS_OWN_SETUP && installers.all { isPackage(it) } &&
|
||||
@@ -212,7 +285,14 @@ object WinComponents {
|
||||
val cabs = HashMap<String, File>()
|
||||
val temp = File(root, "$MSI_CACHE/.t-${c.name}").apply { FileUtils.delete(this) }
|
||||
try {
|
||||
for (step in c.steps) when (step.action) {
|
||||
if (hasSnapshot(c)) {
|
||||
val (result, problem) = installSnapshot(context, c, staging, work, onProgress)
|
||||
if (result == null) return problem
|
||||
msi = result
|
||||
}
|
||||
for (step in c.steps) when {
|
||||
hasSnapshot(c) && step.action in SNAPSHOT_COVERS -> Unit
|
||||
else -> when (step.action) {
|
||||
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
|
||||
// same folder, the first one names the component.
|
||||
"install_msi", "install_exe" -> {
|
||||
@@ -337,6 +417,7 @@ object WinComponents {
|
||||
}
|
||||
"register_dll" -> Unit
|
||||
else -> return "unsupported step ${step.action}"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runtimeDirs.forEach { FileUtils.delete(it) }
|
||||
@@ -355,7 +436,7 @@ object WinComponents {
|
||||
.put("id", c.name).put("version", version)
|
||||
.put("overrides", JSONArray(overrides.distinct()))
|
||||
.put("requires", JSONArray(c.dependencies))
|
||||
msi?.let { meta.put("kind", "msi").put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
|
||||
msi?.let { meta.put("kind", it.optString("kind", "msi")).put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
|
||||
FileUtils.writeString(File(staging, "component.json"), meta.toString(1))
|
||||
val target = File(root, "$STORE/${c.name}")
|
||||
FileUtils.delete(target)
|
||||
@@ -363,6 +444,94 @@ object WinComponents {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the component out from its recording: downloads the snapshot, then its files (the
|
||||
* archive beside it when the recording carries them, else out of the vendor's installer by
|
||||
* the paths the recording maps, small generated ones inline) into [staging]/drive_c, and the
|
||||
* recorded registry values into registry.json. Returns a result like the MSI engine's, or
|
||||
* null and why.
|
||||
*/
|
||||
private fun installSnapshot(context: Context, c: Component, staging: File, work: File, onProgress: (Progress) -> Unit): Pair<JSONObject?, String> {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
val name = c.snapshot.substringAfterLast('/')
|
||||
val json = File(work, "${c.name}.snapshot.json")
|
||||
if (!Downloader.downloadFile(c.snapshot, json, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
|
||||
return null to "download failed: $name"
|
||||
}
|
||||
val snapshot = runCatching { JSONObject(json.readText()) }.getOrNull() ?: return null to "the recording could not be read"
|
||||
if (snapshot.optInt("schema") != 1) return null to "the recording is of a newer kind than this app reads"
|
||||
val files = snapshot.optJSONArray("files") ?: JSONArray()
|
||||
val notes = ArrayList<String>()
|
||||
snapshot.optJSONArray("notes")?.let { for (i in 0 until it.length()) notes += it.getString(i) }
|
||||
val drive = File(staging, "drive_c")
|
||||
var placed = 0
|
||||
val archive = snapshot.optJSONObject("files_archive")
|
||||
if (archive != null) {
|
||||
val archiveName = archive.optString("name")
|
||||
val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName
|
||||
val file = File(work, "${c.name}.files.tar.xz")
|
||||
if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
|
||||
return null to "download failed: $archiveName"
|
||||
}
|
||||
archive.optString("sha256").takeIf { it.length == 64 }?.let { sha ->
|
||||
if (!digest(file, "SHA-256").equals(sha, ignoreCase = true)) return null to "checksum mismatch: $archiveName"
|
||||
}
|
||||
onProgress(Progress(c.name, "unpacking $archiveName", Phase.INSTALL, -1))
|
||||
if (!extract(file, staging)) return null to "could not unpack $archiveName"
|
||||
file.delete()
|
||||
placed = drive.walkTopDown().count { it.isFile }
|
||||
} else {
|
||||
// The files come out of the installer the catalog names, by the paths the recording maps.
|
||||
val step = c.steps.firstOrNull { it.action in INSTALLER_STEPS && it.str("url").startsWith("https://") }
|
||||
?: return null to "the recording needs the installer, which the catalog does not name"
|
||||
val url = step.str("url")
|
||||
val installerName = url.substringBefore('?').substringAfterLast('/')
|
||||
val cache = File(root, MSI_CACHE).apply { mkdirs() }
|
||||
val installer = File(cache, "${c.name}-snapshot-src.bin")
|
||||
var unpacked: File? = null
|
||||
try {
|
||||
if (!Downloader.downloadFile(url, installer, false) { f -> onProgress(Progress(c.name, installerName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
|
||||
return null to "download failed: $installerName"
|
||||
}
|
||||
unpacked = unpackInRuntime(context, c, installer, "snapshot", onProgress) ?: return null to "could not open $installerName"
|
||||
var missing = 0
|
||||
for (i in 0 until files.length()) {
|
||||
val entry = files.getJSONObject(i)
|
||||
val rel = entry.optString("path")
|
||||
val target = ArchivePaths.inside(drive, rel) ?: continue
|
||||
val source = entry.optString("source")
|
||||
when {
|
||||
source.isNotEmpty() -> ArchivePaths.inside(unpacked, source)?.takeIf { it.isFile }?.let { src ->
|
||||
target.parentFile?.mkdirs(); src.copyTo(target, overwrite = true); placed++
|
||||
} ?: run { missing++ }
|
||||
entry.has("data") -> {
|
||||
target.parentFile?.mkdirs()
|
||||
target.writeBytes(android.util.Base64.decode(entry.getString("data"), android.util.Base64.DEFAULT)); placed++
|
||||
}
|
||||
else -> missing++
|
||||
}
|
||||
if (i % 20 == 0) onProgress(Progress(c.name, "placing files ($placed of ${files.length()})", Phase.INSTALL, (i + 1) * 100 / maxOf(1, files.length())))
|
||||
}
|
||||
if (missing > 0) notes += "$missing file(s) the installer generates could not be reproduced here"
|
||||
} finally {
|
||||
installer.delete()
|
||||
unpacked?.let { FileUtils.delete(it) }
|
||||
}
|
||||
}
|
||||
onProgress(Progress(c.name, "writing the registry values", Phase.INSTALL, 95))
|
||||
val values = snapshot.optJSONArray("registry") ?: JSONArray()
|
||||
FileUtils.writeString(File(staging, "registry.json"), JSONObject().put("version", 1).put("values", values).toString(1))
|
||||
if (placed == 0 && values.length() == 0) return null to "the recording holds nothing to install"
|
||||
val recorded = snapshot.optString("recorded").take(10)
|
||||
val installerVersion = snapshot.optJSONObject("installer")?.optString("name").orEmpty()
|
||||
val version = Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: recorded.ifEmpty { "recorded" }
|
||||
return JSONObject()
|
||||
.put("kind", "snapshot").put("product", c.description.ifEmpty { c.name })
|
||||
.put("version", version)
|
||||
.put("counts", JSONObject().put("files", placed).put("registry", values.length()))
|
||||
.put("notes", JSONArray(notes + listOfNotNull(installerVersion.takeIf { it.isNotEmpty() }?.let { "recorded from $it on ${snapshot.optString("wine")}" }))) to ""
|
||||
}
|
||||
|
||||
/** The regex for a catalog file pattern: * matches anything, case does not matter. */
|
||||
private fun globRegex(pattern: String) =
|
||||
Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
|
||||
@@ -378,7 +547,7 @@ object WinComponents {
|
||||
val inside = archive.relativeTo(root).path
|
||||
var problem: String? = null
|
||||
var ok = false
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}"),
|
||||
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}")),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, _) -> onProgress(Progress(c.name, text, Phase.INSTALL, -1)) }
|
||||
@@ -417,7 +586,7 @@ object WinComponents {
|
||||
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
|
||||
var result: JSONObject? = null
|
||||
var problem: String? = null
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
|
||||
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}")),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, pct) -> onProgress(Progress(c.name, text, Phase.INSTALL, pct)) }
|
||||
@@ -494,36 +663,101 @@ object WinComponents {
|
||||
fun uninstall(context: Context, id: String) {
|
||||
require(validId(id))
|
||||
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
|
||||
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
|
||||
save(context, selections(context).mapValues { (_, sel) -> Selection(sel.auto - id, sel.user - id) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a component was turned on for a game by itself ([setAuto]): [kind] says whose list it came
|
||||
* from (see AutoComponents), [args] what the reason line names. Kept beside the picks.
|
||||
*/
|
||||
class AutoReason(val kind: String, val args: List<String>)
|
||||
|
||||
/**
|
||||
* Everything kept per game, by the appid its prefix is named after: [auto] - turned on by
|
||||
* itself, with its reason; [user] - a switch the user pressed (true on, false off), which wins
|
||||
* over [auto] either way. What the launch reads is [effective].
|
||||
*/
|
||||
class Selection(val auto: Map<String, AutoReason>, val user: Map<String, Boolean>) {
|
||||
val effective: List<String>
|
||||
get() = (auto.keys.filter { user[it] != false } + user.filterValues { it }.keys).distinct()
|
||||
}
|
||||
|
||||
/** Each game's picks, by the appid its prefix is named after. */
|
||||
@Synchronized
|
||||
fun read(context: Context): Map<String, List<String>> = try {
|
||||
fun selections(context: Context): Map<String, Selection> = try {
|
||||
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
|
||||
val games = json.optJSONObject("games") ?: JSONObject()
|
||||
games.keys().asSequence().associateWith { app ->
|
||||
val ids = games.getJSONArray(app)
|
||||
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
|
||||
val autos = json.optJSONObject("auto") ?: JSONObject()
|
||||
val users = json.optJSONObject("user")
|
||||
val keys = games.keys().asSequence().toSet() + autos.keys().asSequence().toSet() + (users?.keys()?.asSequence()?.toSet() ?: emptySet())
|
||||
keys.associateWith { app ->
|
||||
val auto = autos.optJSONObject(app)?.let { o ->
|
||||
o.keys().asSequence().filter { validId(it) }.associateWith { id ->
|
||||
val r = o.optJSONObject(id) ?: JSONObject()
|
||||
val a = r.optJSONArray("args") ?: JSONArray()
|
||||
AutoReason(r.optString("kind"), (0 until a.length()).map { a.optString(it) })
|
||||
}
|
||||
}.orEmpty()
|
||||
// Before there were overrides, every pick was the user's own.
|
||||
val user = if (users == null) games.optJSONArray(app)?.let { ids -> (0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }.associateWith { true } }.orEmpty()
|
||||
else users.optJSONObject(app)?.let { o -> o.keys().asSequence().filter { validId(it) }.associateWith { o.optBoolean(it) } }.orEmpty()
|
||||
Selection(auto, user)
|
||||
}
|
||||
} catch (_: java.io.FileNotFoundException) {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
|
||||
/** Each game's picks as the launch takes them, by the appid its prefix is named after. */
|
||||
fun read(context: Context): Map<String, List<String>> =
|
||||
selections(context).mapValues { it.value.effective }.filterValues { it.isNotEmpty() }
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = selections(context)[appKey]?.effective.orEmpty()
|
||||
|
||||
fun selection(context: Context, appKey: String): Selection = selections(context)[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
|
||||
/** The user's own switch for [id] in [appKey]: on or off, over whatever was picked by itself. */
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = read(context).toMutableMap()
|
||||
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
|
||||
fun setUser(context: Context, appKey: String, id: String, on: Boolean) {
|
||||
require(validId(id))
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(now.auto, now.user + (id to on))
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, List<String>>) {
|
||||
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
|
||||
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
|
||||
}).toString()
|
||||
/** The components picked by themselves for [appKey], replacing the earlier ones; the user's switches stay. */
|
||||
@Synchronized
|
||||
fun setAuto(context: Context, appKey: String, auto: Map<String, AutoReason>) {
|
||||
require(auto.keys.all { validId(it) })
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(auto, now.user)
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
/** The user's picks for [appKey] set outright (each listed on, every other user switch dropped). */
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(now.auto, ids.distinct().associateWith { true } + now.auto.keys.filter { it !in ids }.associateWith { false })
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, Selection>) {
|
||||
val kept = games.filterValues { it.auto.isNotEmpty() || it.user.isNotEmpty() }
|
||||
// Version 1 still: droiddeck-wincomponents reads "games" and ignores the rest.
|
||||
val text = JSONObject().put("version", 1)
|
||||
.put("games", JSONObject().apply { kept.forEach { (app, sel) -> sel.effective.takeIf { it.isNotEmpty() }?.let { put(app, JSONArray(it)) } } })
|
||||
.put("auto", JSONObject().apply {
|
||||
kept.forEach { (app, sel) ->
|
||||
if (sel.auto.isNotEmpty()) put(app, JSONObject().apply {
|
||||
sel.auto.forEach { (id, r) -> put(id, JSONObject().put("kind", r.kind).put("args", JSONArray(r.args))) }
|
||||
})
|
||||
}
|
||||
})
|
||||
.put("user", JSONObject().apply { kept.forEach { (app, sel) -> if (sel.user.isNotEmpty()) put(app, JSONObject(sel.user as Map<*, *>)) } })
|
||||
.toString()
|
||||
write(File(context.filesDir, SELECTION), text)
|
||||
publish(context, text)
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ object SteamLiveShortcuts {
|
||||
private const val TAG_NAME = "droiddeck-app"
|
||||
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
|
||||
|
||||
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
|
||||
fun sync(context: Context, games: List<AddedGames.Game>) {
|
||||
val wanted = games.filter { it.source != Library.ADDED }
|
||||
/** Adds every game in [games] that [include] takes (store games by default) and the client does not list yet. Blocking; worker thread. */
|
||||
fun sync(context: Context, games: List<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) {
|
||||
val wanted = games.filter(include)
|
||||
if (wanted.isEmpty() || !clientRunning()) return
|
||||
val list = JSONArray()
|
||||
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
|
||||
@@ -71,6 +71,47 @@ object SteamLiveShortcuts {
|
||||
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
|
||||
}
|
||||
|
||||
/**
|
||||
* The running client's shortcut for [game] made to match it: name, target, Start in and
|
||||
* launch options, on the same appid (an edit never adds a second shortcut). Blocking.
|
||||
*/
|
||||
fun update(context: Context, game: AddedGames.Game) {
|
||||
if (!clientRunning()) return
|
||||
val id = game.appId and 0xFFFFFFFFL
|
||||
val js = """
|
||||
(async () => {
|
||||
const id = $id;
|
||||
try { SteamClient.Apps.SetShortcutName(id, ${JSONObject.quote(game.name)}); } catch (e) {}
|
||||
try { SteamClient.Apps.SetShortcutExe(id, ${JSONObject.quote("\"" + game.guestExe + "\"")}); } catch (e) {}
|
||||
try { SteamClient.Apps.SetShortcutStartDir(id, ${JSONObject.quote("\"" + game.guestDir + "\"")}); } catch (e) {}
|
||||
try { SteamClient.Apps.SetShortcutLaunchOptions(id, ${JSONObject.quote(game.launchOptions)}); } catch (e) {}
|
||||
return 1;
|
||||
})()
|
||||
""".trimIndent()
|
||||
if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: updated $id")
|
||||
}
|
||||
|
||||
/**
|
||||
* One piece of art for the running client's shortcut [appId]: [assetType] as the client numbers
|
||||
* them (0 portrait capsule, 1 hero, 2 logo, 3 wide capsule), from [file]; null clears it. The
|
||||
* icon goes by path ([iconPath]). Blocking.
|
||||
*/
|
||||
fun setArt(context: Context, appId: Long, assetType: Int?, file: java.io.File?, iconPath: String? = null) {
|
||||
if (!clientRunning()) return
|
||||
val id = appId and 0xFFFFFFFFL
|
||||
val js = when {
|
||||
iconPath != null -> "(async () => { try { SteamClient.Apps.SetShortcutIcon($id, ${JSONObject.quote(iconPath)}); } catch (e) {} return 1; })()"
|
||||
assetType == null -> return
|
||||
file == null -> "(async () => { try { await SteamClient.Apps.ClearCustomArtworkForApp($id, $assetType); } catch (e) {} return 1; })()"
|
||||
else -> {
|
||||
val data = runCatching { Base64.encodeToString(file.readBytes(), Base64.NO_WRAP) }.getOrNull() ?: return
|
||||
val type = if (file.extension.equals("png", true)) "png" else "jpg"
|
||||
"(async () => { try { await SteamClient.Apps.SetCustomArtworkForApp($id, ${JSONObject.quote(data)}, ${JSONObject.quote(type)}, $assetType); } catch (e) {} return 1; })()"
|
||||
}
|
||||
}
|
||||
if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: art $assetType for $id")
|
||||
}
|
||||
|
||||
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
|
||||
|
||||
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
|
||||
|
||||
@@ -52,6 +52,9 @@ object StoreInstalls {
|
||||
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
|
||||
onStep?.invoke(2, STEPS)
|
||||
register(app)
|
||||
// Its Windows components from the store's list (GOG's dependencies, Epic's prerequisite,
|
||||
// Amazon's install steps), on a thread of their own.
|
||||
AddedGames.single(app, folder.path)?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, Library.addedGame(app, it)) }
|
||||
onStep?.invoke(STEPS, STEPS)
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ object StoreInstalls {
|
||||
try {
|
||||
val games = AddedGames.scan(app)
|
||||
AddedGames.writeListing(app, games)
|
||||
LibraryCache.save(app, Library.launchableGames(app))
|
||||
LibraryCache.save(app, Library.launchableGames(app, added = games))
|
||||
SteamLiveShortcuts.sync(app, games)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "registration: ${e.message}")
|
||||
|
||||
@@ -25,13 +25,15 @@ public final class AmazonApiClient {
|
||||
private static final String TAG = "AmazonApi";
|
||||
private static final String ENTITLEMENTS_URL = "https://gaming.amazon.com/api/distribution/entitlements";
|
||||
private static final String DISTRIBUTION_URL = "https://gaming.amazon.com/api/distribution/v2/public";
|
||||
private static final String GAMING_USER_AGENT = "com.amazon.agslauncher.win/3.0.9202.1";
|
||||
// As nile sends them: the session's User-Agent, and the launcher's own string under "UserAgent".
|
||||
private static final String GAMING_USER_AGENT = "AGSLauncher/1.0.0";
|
||||
private static final String LAUNCHER_USER_AGENT = "com.amazon.agslauncher.win/3.0.9202.1";
|
||||
private static final String DOWNLOAD_USER_AGENT = "nile/0.1 Amazon";
|
||||
private static final String KEY_ID = "d5dc8b8b-86c8-4fc4-ae93-18c0def5314d";
|
||||
|
||||
private AmazonApiClient() {}
|
||||
|
||||
/** The owned games, every page, one per productId. */
|
||||
/** The owned games, every page, one per productId; null when Amazon refused or could not be reached. */
|
||||
public static List<AmazonGame> getEntitlements(String accessToken, String deviceSerial) {
|
||||
Map<String, AmazonGame> seen = new HashMap<>();
|
||||
String nextToken = null;
|
||||
@@ -40,8 +42,9 @@ public final class AmazonApiClient {
|
||||
hardwareHash = AmazonPkce.sha256Upper(deviceSerial);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "hardware hash failed", e);
|
||||
return new ArrayList<>();
|
||||
return null;
|
||||
}
|
||||
boolean answered = false;
|
||||
do {
|
||||
try {
|
||||
JSONObject body = new JSONObject();
|
||||
@@ -56,19 +59,22 @@ public final class AmazonApiClient {
|
||||
String resp = postGaming(ENTITLEMENTS_URL, "com.amazon.animusdistributionservice.entitlement.AnimusEntitlementsService.GetEntitlements", accessToken, body.toString());
|
||||
if (resp == null) break;
|
||||
JSONObject json = new JSONObject(resp);
|
||||
answered = true;
|
||||
JSONArray entitlements = json.optJSONArray("entitlements");
|
||||
if (entitlements == null) break;
|
||||
for (int i = 0; i < entitlements.length(); i++) {
|
||||
if (entitlements != null) for (int i = 0; i < entitlements.length(); i++) {
|
||||
AmazonGame game = parseEntitlement(entitlements.getJSONObject(i));
|
||||
if (game != null && !game.productId.isEmpty()) seen.put(game.productId, game);
|
||||
}
|
||||
nextToken = json.optString("nextToken", null);
|
||||
// A JSON null reads as "null" through optString; the last page carries none.
|
||||
nextToken = json.isNull("nextToken") ? null : json.optString("nextToken", "");
|
||||
if (nextToken != null && nextToken.isEmpty()) nextToken = null;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "entitlements page failed: " + e.getClass().getSimpleName());
|
||||
break;
|
||||
}
|
||||
} while (nextToken != null);
|
||||
if (!answered) return null;
|
||||
Log.i(TAG, "entitlements: " + seen.size());
|
||||
return new ArrayList<>(seen.values());
|
||||
}
|
||||
|
||||
@@ -140,7 +146,9 @@ public final class AmazonApiClient {
|
||||
JSONObject body = new JSONObject().put("adgProductIds", new JSONArray().put(productId)).put("Operation", "GetLiveVersionIds");
|
||||
String resp = postGaming(DISTRIBUTION_URL, "com.amazon.animusdistributionservice.external.AnimusDistributionService.GetLiveVersionIds", accessToken, body.toString());
|
||||
if (resp == null) return null;
|
||||
JSONObject versions = new JSONObject(resp).optJSONObject("versionIds");
|
||||
JSONObject json = new JSONObject(resp);
|
||||
JSONObject versions = json.optJSONObject("adgProductIdToVersionIdMap");
|
||||
if (versions == null) versions = json.optJSONObject("versionIds");
|
||||
return versions == null ? null : versions.optString(productId, null);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "GetLiveVersionIds failed: " + e.getClass().getSimpleName());
|
||||
@@ -165,13 +173,17 @@ public final class AmazonApiClient {
|
||||
conn.setRequestProperty("X-Amz-Target", target);
|
||||
conn.setRequestProperty("x-amzn-token", accessToken);
|
||||
conn.setRequestProperty("User-Agent", GAMING_USER_AGENT);
|
||||
conn.setRequestProperty("UserAgent", LAUNCHER_USER_AGENT);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setRequestProperty("Content-Encoding", "amz-1.0");
|
||||
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
|
||||
int code = conn.getResponseCode();
|
||||
String resp = AmazonAuthClient.readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
|
||||
conn.disconnect();
|
||||
if (code < 200 || code >= 300) { Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
|
||||
if (code < 200 || code >= 300) {
|
||||
Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr) + ": " + (resp.length() > 200 ? resp.substring(0, 200) : resp));
|
||||
return null;
|
||||
}
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "POST failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
|
||||
|
||||
@@ -39,6 +39,8 @@ public final class AmazonAuthClient {
|
||||
public String refreshToken;
|
||||
public long expiresIn;
|
||||
public String name;
|
||||
/** The serial Amazon registered the device under; the entitlements call hashes this one. */
|
||||
public String deviceSerial;
|
||||
}
|
||||
|
||||
private AmazonAuthClient() {}
|
||||
@@ -78,8 +80,12 @@ public final class AmazonAuthClient {
|
||||
result.accessToken = bearer.getString("access_token");
|
||||
result.refreshToken = bearer.getString("refresh_token");
|
||||
result.expiresIn = bearer.optLong("expires_in", 3600L);
|
||||
JSONObject customer = success.optJSONObject("extensions") != null ? success.getJSONObject("extensions").optJSONObject("customer_info") : null;
|
||||
JSONObject ext = success.optJSONObject("extensions");
|
||||
JSONObject customer = ext != null ? ext.optJSONObject("customer_info") : null;
|
||||
result.name = customer != null ? customer.optString("name", "") : "";
|
||||
JSONObject device = ext != null ? ext.optJSONObject("device_info") : null;
|
||||
String registered = device != null ? device.optString("device_serial_number", "") : "";
|
||||
result.deviceSerial = registered.isEmpty() ? deviceSerial : registered;
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "registerDevice failed: " + e.getClass().getSimpleName());
|
||||
|
||||
@@ -75,10 +75,9 @@ object AmazonBackend : StoreBackend {
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filter { it.id !in installed }, free = emptyList(), deals = emptyList(), whatsNew = emptyList())
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
// Nothing public to fetch; the shelves follow the library sync.
|
||||
if (!StoresState.library.containsKey(store)) syncLibrary(context, force)
|
||||
}
|
||||
// Nothing public to fetch; the shelves follow the library sync, which StoresState.open starts
|
||||
// alongside this. A second sync here only came back Busy and cleared the first one's status.
|
||||
override fun loadShelves(context: Context, force: Boolean) {}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
@@ -119,7 +118,7 @@ object AmazonBackend : StoreBackend {
|
||||
val exe = spec.exeRelative.ifEmpty { StoreExe.pick(folder, null, game.title) }
|
||||
if (exe.isEmpty()) handle.log("amazon: no exe found in ${folder.name}; pick one in Steam settings › Added games")
|
||||
val env = LinkedHashMap<String, String>()
|
||||
for (kv in AmazonLaunchHelper.buildFuelEnv(game.entitlementId, game.productSku)) env[kv.substringBefore('=')] = kv.substringAfter('=')
|
||||
for (kv in AmazonLaunchHelper.buildFuelEnv(game.entitlementId, game.productSku, com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, Store.AMAZON)?.takeIf { it != Store.AMAZON.label })) env[kv.substringBefore('=')] = kv.substringAfter('=')
|
||||
val sidecar = StoreGameSidecar(
|
||||
Store.AMAZON, game.productId, game.title, exe = exe.ifEmpty { "game.exe" }, args = spec.args, env = env,
|
||||
installVersion = result.versionId ?: "", installedAt = System.currentTimeMillis(),
|
||||
@@ -159,7 +158,7 @@ object AmazonBackend : StoreBackend {
|
||||
val code = Uri.parse(url).getQueryParameter("openid.oa2.authorization_code")
|
||||
if (code.isNullOrEmpty()) { Log.w(TAG, "login: return URL without a code"); return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification) }
|
||||
val result = AmazonAuthClient.registerDevice(code, verifier, serial, clientId) ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
|
||||
AmazonCredentialStore.save(activity, result.accessToken, result.refreshToken, serial, clientId, System.currentTimeMillis() + result.expiresIn * 1000L, result.name)
|
||||
AmazonCredentialStore.save(activity, result.accessToken, result.refreshToken, result.deviceSerial ?: serial, clientId, System.currentTimeMillis() + result.expiresIn * 1000L, result.name)
|
||||
Log.i(TAG, "signed in")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ object AmazonCredentialStore {
|
||||
@JvmStatic
|
||||
fun isLoggedIn(context: Context): Boolean = load(context) != null
|
||||
|
||||
/** A fresh access token whatever the stored expiry says: for a call Amazon refused with the stored one. */
|
||||
@JvmStatic
|
||||
fun refresh(context: Context): String? {
|
||||
val creds = load(context) ?: return null
|
||||
val result = AmazonAuthClient.refreshAccessToken(creds.refreshToken ?: return null) ?: return null
|
||||
save(context, result.accessToken, creds.refreshToken, creds.deviceSerial, creds.clientId, System.currentTimeMillis() + result.expiresIn * 1000L, null)
|
||||
Log.i(TAG, "token refreshed (forced)")
|
||||
return result.accessToken
|
||||
}
|
||||
|
||||
/** A valid access token, refreshed within five minutes of expiry; null when signed out or the refresh failed. */
|
||||
@JvmStatic
|
||||
fun getValidAccessToken(context: Context): String? {
|
||||
|
||||
@@ -66,13 +66,13 @@ public final class AmazonLaunchHelper {
|
||||
}
|
||||
|
||||
/** The FuelPump variables, as KEY=VALUE, for the game's environment. */
|
||||
public static String[] buildFuelEnv(String entitlementId, String productSku) {
|
||||
public static String[] buildFuelEnv(String entitlementId, String productSku, String displayName) {
|
||||
return new String[]{
|
||||
"FUEL_DIR=C:\\ProgramData\\Amazon Games Services\\Legacy",
|
||||
"AMAZON_GAMES_SDK_PATH=C:\\ProgramData\\Amazon Games Services\\AmazonGamesSDK",
|
||||
"AMAZON_GAMES_FUEL_ENTITLEMENT_ID=" + entitlementId,
|
||||
"AMAZON_GAMES_FUEL_PRODUCT_SKU=" + productSku,
|
||||
"AMAZON_GAMES_FUEL_DISPLAY_NAME=Player",
|
||||
"AMAZON_GAMES_FUEL_DISPLAY_NAME=" + (displayName == null || displayName.isEmpty() ? "Player" : displayName),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,11 @@ object AmazonLibrary {
|
||||
val creds = AmazonCredentialStore.load(app) ?: return SyncResult.NotLoggedIn
|
||||
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return SyncResult.Failed(StoresState.notSignedInLine(Store.AMAZON))
|
||||
onStatus("Fetching game list…")
|
||||
// Refused with the stored token (revoked, or an expiry read wrong): once more on a fresh one.
|
||||
val all = AmazonApiClient.getEntitlements(token, creds.deviceSerial)
|
||||
if (all.isNullOrEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
|
||||
?: AmazonCredentialStore.refresh(app)?.let { AmazonApiClient.getEntitlements(it, creds.deviceSerial) }
|
||||
if (all == null) return if (cachedList.isEmpty()) SyncResult.Failed("Amazon Games did not answer for your library: refresh, or sign in again") else SyncResult.Ok(cachedList)
|
||||
if (all.isEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
|
||||
val games = all.filter { !(it.isDLC && it.parentProductId.isNotEmpty()) }.ifEmpty { all }
|
||||
.sortedWith { a, b -> a.title.compareTo(b.title, ignoreCase = true) }
|
||||
for (fresh in games) cachedList.firstOrNull { it.productId == fresh.productId }?.let { fresh.versionId = it.versionId }
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
import com.droiddeck.launcher.stores.formatSpeed
|
||||
@@ -134,7 +135,7 @@ class StoreDownloadService : Service() {
|
||||
private fun locks() {
|
||||
val running = StoresState.latestDownloads.any { it.state == DownloadState.RUNNING }
|
||||
if (!running) { releaseLocks(); return }
|
||||
if (wifi == null) {
|
||||
if (wifi == null && DeviceSupport.lowLatencyWifiSafe()) {
|
||||
runCatching {
|
||||
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -156,7 +156,11 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
||||
Store.EPIC, item.id, item.title, exe = exe.ifEmpty { "game.exe" }, args = args,
|
||||
installVersion = result.buildVersion, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
|
||||
// Both kept even when empty: the manifest was read, and names no prerequisite.
|
||||
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId) + mapOf(
|
||||
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_PATH to result.prereqPath,
|
||||
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_NAME to result.prereqName,
|
||||
) + (if (result.prereqRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar)
|
||||
return folder.path
|
||||
|
||||
@@ -107,6 +107,11 @@ public final class EpicDownloadManager {
|
||||
public String launchExe = "";
|
||||
public String launchCommand = "";
|
||||
public String buildVersion = "";
|
||||
/** The prerequisite the launcher runs first (UE4PrereqSetup_x64.exe...): what it is called and its path in the install. */
|
||||
public String prereqName = "";
|
||||
public String prereqPath = "";
|
||||
/** The prerequisite fields were read (an empty name and path then mean the build names none). */
|
||||
public boolean prereqRead = false;
|
||||
public List<CdnUrl> cdnUrls = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -115,7 +120,12 @@ public final class EpicDownloadManager {
|
||||
public final String launchExe;
|
||||
public final String buildVersion;
|
||||
public final long bytes;
|
||||
/** The manifest's prerequisite, for Windows components; "" when it names none. */
|
||||
public String prereqName = "";
|
||||
public String prereqPath = "";
|
||||
Result(String launchExe, String buildVersion, long bytes) { this.launchExe = launchExe; this.buildVersion = buildVersion; this.bytes = bytes; }
|
||||
public boolean prereqRead = false;
|
||||
Result withPrereq(Manifest m) { prereqName = m.prereqName; prereqPath = m.prereqPath; prereqRead = m.prereqRead; return this; }
|
||||
}
|
||||
|
||||
public static final class InstallException extends Exception {
|
||||
@@ -178,7 +188,7 @@ public final class EpicDownloadManager {
|
||||
if (pending.isEmpty()) {
|
||||
deleteDir(chunkCacheDir);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
}
|
||||
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
|
||||
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
|
||||
@@ -265,7 +275,7 @@ public final class EpicDownloadManager {
|
||||
if (r.cancelled) return null;
|
||||
if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
}
|
||||
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
|
||||
}
|
||||
@@ -293,7 +303,7 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
deleteDir(chunkCacheDir);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
} catch (InstallException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -470,6 +480,13 @@ public final class EpicDownloadManager {
|
||||
m.buildVersion = readFString(b);
|
||||
m.launchExe = readFString(b).replace('\\', '/');
|
||||
m.launchCommand = readFString(b);
|
||||
// PrereqIds (an array), PrereqName, PrereqPath, PrereqArgs.
|
||||
int prereqIds = b.getInt();
|
||||
for (int i = 0; i < prereqIds && i < 64; i++) readFString(b);
|
||||
m.prereqName = readFString(b);
|
||||
m.prereqPath = readFString(b).replace('\\', '/');
|
||||
readFString(b); // PrereqArgs
|
||||
m.prereqRead = true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "manifest meta: " + e.getClass().getSimpleName());
|
||||
}
|
||||
@@ -537,6 +554,9 @@ public final class EpicDownloadManager {
|
||||
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
|
||||
m.launchCommand = root.optString("LaunchCommand", "");
|
||||
m.buildVersion = root.optString("BuildVersionString", "");
|
||||
m.prereqName = root.optString("PrereqName", "");
|
||||
m.prereqPath = root.optString("PrereqPath", "").replace('\\', '/');
|
||||
m.prereqRead = true;
|
||||
JSONObject chunkHashList = root.optJSONObject("ChunkHashList");
|
||||
JSONObject dataGroupList = root.optJSONObject("DataGroupList");
|
||||
JSONObject chunkFilesizeList = root.optJSONObject("ChunkFilesizeList");
|
||||
@@ -753,6 +773,22 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The manifest's prerequisite as {name, path}, from the manifest alone; null on failure. Blocking. */
|
||||
public static String[] fetchPrereq(String manifestApiJson) {
|
||||
try {
|
||||
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
|
||||
if (cdnUrls.isEmpty()) return null;
|
||||
byte[] bytes = downloadManifest(manifestApiJson, cdnUrls);
|
||||
if (bytes == null) return null;
|
||||
Manifest m = parseManifest(bytes);
|
||||
if (m == null || !m.prereqRead) { Log.w(TAG, "manifest prerequisite not read"); return null; }
|
||||
Log.i(TAG, "manifest " + m.buildVersion + ": " + m.files.size() + " files, prerequisite " + (m.prereqName.isEmpty() && m.prereqPath.isEmpty() ? "none" : m.prereqName + " (" + m.prereqPath + ")"));
|
||||
return new String[] { m.prereqName, m.prereqPath };
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The install size for the tags (null = every file), from the manifest alone; -1 on failure. Blocking. */
|
||||
public static long fetchInstallSizeBytes(String manifestApiJson, List<String> installTags) {
|
||||
try {
|
||||
|
||||
@@ -111,6 +111,9 @@ object GogBackend : StoreBackend {
|
||||
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
|
||||
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
// Kept even when empty: the build was read, and lists none.
|
||||
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
|
||||
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
)
|
||||
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
|
||||
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
|
||||
|
||||
@@ -79,6 +79,10 @@ public final class GogDownloadManager {
|
||||
public final String exeRelative;
|
||||
public final String buildId;
|
||||
public final long bytes;
|
||||
/** The build manifest's "dependencies" (GOG's redistributable ids: MSVC2017_x64, DirectX...), for Windows components. */
|
||||
public List<String> dependencies = new ArrayList<>();
|
||||
/** The dependencies come from a gen2 build manifest read whole (gen1 builds and installers have none). */
|
||||
public boolean dependenciesRead = false;
|
||||
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
|
||||
}
|
||||
|
||||
@@ -150,10 +154,12 @@ public final class GogDownloadManager {
|
||||
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
|
||||
baseProductId = products.getJSONObject(0).optString("productId", null);
|
||||
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
|
||||
// The build's required redistributables (MSVC, DirectX, ...) are recorded for the engine
|
||||
// log; Steam's shared redistributables run in the prefix on first launch as for any shortcut.
|
||||
// The build's required redistributables (MSVC, DirectX, ...): logged, and kept in the
|
||||
// sidecar, where Windows components take them as this game's list (WinCompSources).
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
|
||||
List<String> dependencies = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
|
||||
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
|
||||
|
||||
cb.onProgress("Reading depot manifests…", 10);
|
||||
List<DepotFile> files = new ArrayList<>();
|
||||
@@ -262,6 +268,8 @@ public final class GogDownloadManager {
|
||||
String clientSecret = manifest.optString("clientSecret", null);
|
||||
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
|
||||
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
|
||||
out.result.dependencies = dependencies;
|
||||
out.result.dependenciesRead = true;
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
@@ -1031,6 +1039,48 @@ public final class GogDownloadManager {
|
||||
}
|
||||
|
||||
/** The install size of the Windows build (English / all-language depots), or -1. Blocking. */
|
||||
/**
|
||||
* The "dependencies" of the build [buildId] of [gameId] (else the newest Windows build), from
|
||||
* its build manifest alone: no game file is fetched. Null when the builds or the manifest
|
||||
* could not be had; empty when the build lists none. Blocking.
|
||||
*/
|
||||
public static List<String> fetchDependencies(String gameId, String buildId, String token) {
|
||||
try {
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
String buildsJson = httpGet(buildsUrl, null);
|
||||
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
|
||||
if (buildsJson == null) return null;
|
||||
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
|
||||
if (items == null || items.length() == 0) return null;
|
||||
String manifestUrl = null;
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject item = items.getJSONObject(i);
|
||||
if (!"windows".equals(item.optString("os"))) continue;
|
||||
String link = item.optString("link");
|
||||
if (link == null || link.isEmpty()) link = item.optString("meta_url");
|
||||
if (manifestUrl == null) manifestUrl = link;
|
||||
if (buildId != null && !buildId.isEmpty() && buildId.equals(item.optString("build_id"))) { manifestUrl = link; break; }
|
||||
}
|
||||
if (manifestUrl == null || manifestUrl.isEmpty()) return null;
|
||||
byte[] raw = fetchBytes(manifestUrl, token);
|
||||
if (raw == null) return null;
|
||||
String manifestStr = decompressBytes(raw);
|
||||
if (manifestStr == null) return null;
|
||||
JSONObject manifest = new JSONObject(manifestStr);
|
||||
// A manifest without depots is not one this reads right: no list rather than an empty one.
|
||||
JSONArray depots = manifest.optJSONArray("depots");
|
||||
if (depots == null) { Log.w(TAG, "gog: build manifest for " + gameId + " has no depots; keys " + manifest.names()); return null; }
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
List<String> out = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) out.add(d); }
|
||||
Log.i(TAG, "gog: build " + manifest.optString("buildId", buildId) + " of " + gameId + ": " + depots.length() + " depots, dependencies "
|
||||
+ (manifest.has("dependencies") ? String.valueOf(deps) : "(none listed)"));
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static long fetchInstallSizeBytes(String gameId, String token) {
|
||||
try {
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -313,12 +314,17 @@ internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolea
|
||||
@Composable
|
||||
internal fun AppDialog(
|
||||
shown: MutableTransitionState<Boolean>, close: () -> Unit, label: String, wide: Boolean,
|
||||
modifier: Modifier = Modifier, maxWidth: Dp? = null, content: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier, maxWidth: Dp? = null,
|
||||
/** [width] fixed, else as wide as its content when [fitContent], else as wide as it may be. */
|
||||
width: Dp? = null, fitContent: Boolean = false,
|
||||
/** What B / Back does, when not [close] (a picker goes up a folder first). */
|
||||
back: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
// The page's ring stays out of sight behind it.
|
||||
VeilRing()
|
||||
Dialog(onDismissRequest = close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
|
||||
Dialog(onDismissRequest = back ?: close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
|
||||
// The page's focus memory stays the page's: nothing in here is a place to come back to.
|
||||
CompositionLocalProvider(LocalFrontFocus provides null) {
|
||||
Box(
|
||||
@@ -337,14 +343,20 @@ internal fun AppDialog(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.widthIn(max = maxWidth ?: if (wide) 860.dp else 560.dp)
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
when {
|
||||
width != null -> Modifier.width(width)
|
||||
fitContent -> Modifier.width(androidx.compose.foundation.layout.IntrinsicSize.Max)
|
||||
else -> Modifier.fillMaxWidth()
|
||||
},
|
||||
)
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 24).dp)
|
||||
.shadow(24.dp, Shape16, ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(Shape16)
|
||||
.background(pal.surfaceVariant.copy(alpha = 0.97f))
|
||||
.border(1.dp, pal.signal.copy(alpha = 0.22f), Shape16)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
|
||||
.controllerBack(close)
|
||||
.controllerBack(back ?: close)
|
||||
.then(modifier)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AddedGameArt
|
||||
import com.droiddeck.launcher.frontend.AddedGameEdits
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/** Each art slot's thumbnail, in its true shape: width, height, corner. */
|
||||
private fun AddedGameArt.Slot.shape(): Triple<Dp, Dp, Dp> = when (this) {
|
||||
AddedGameArt.Slot.COVER -> Triple(28.dp, 41.dp, 5.dp)
|
||||
AddedGameArt.Slot.BACKGROUND -> Triple(60.dp, 34.dp, 5.dp)
|
||||
AddedGameArt.Slot.LOGO -> Triple(60.dp, 26.dp, 5.dp)
|
||||
AddedGameArt.Slot.ICON -> Triple(30.dp, 30.dp, 7.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddedGameArt.Slot.label(): String = stringResource(
|
||||
when (this) {
|
||||
AddedGameArt.Slot.COVER -> R.string.art_cover
|
||||
AddedGameArt.Slot.BACKGROUND -> R.string.art_background
|
||||
AddedGameArt.Slot.LOGO -> R.string.art_logo
|
||||
AddedGameArt.Slot.ICON -> R.string.art_icon
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun artSourceLabel(source: String): String = stringResource(
|
||||
when (source) {
|
||||
AddedGameArt.FOLDER -> R.string.art_src_folder
|
||||
AddedGameArt.STEAM -> R.string.art_src_steam
|
||||
AddedGameArt.SGDB -> R.string.art_src_sgdb
|
||||
AddedGameArt.FILE -> R.string.art_src_file
|
||||
AddedGameArt.EXE -> R.string.art_src_exe
|
||||
else -> R.string.art_src_auto
|
||||
},
|
||||
)
|
||||
|
||||
/** A thumbnail of [model] (a file or URL) in [slot]'s shape, scaled by [scale]. */
|
||||
@Composable
|
||||
private fun SlotThumb(slot: AddedGameArt.Slot, model: Any?, scale: Float = 1f, selected: Boolean = false) {
|
||||
val pal = LocalPalette.current
|
||||
val (w, h, r) = slot.shape()
|
||||
val shape = RoundedCornerShape(r)
|
||||
Box(
|
||||
Modifier.size(w * scale, h * scale).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape),
|
||||
) {
|
||||
if (model != null) AsyncImage(
|
||||
model = model, contentDescription = null,
|
||||
contentScale = if (slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON) ContentScale.Fit else ContentScale.Crop,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A row of the editor: a label column, then the value. */
|
||||
@Composable
|
||||
private fun FieldRow(label: String, focus: FocusRequester? = null, onClick: () -> Unit, value: @Composable () -> Unit) {
|
||||
ListRow(focus = focus, onClick = onClick) {
|
||||
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(110.dp))
|
||||
Box(Modifier.weight(1f)) { value() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the editor opens: its game's folder, straight on Target, and whether it came from the added-games summary. */
|
||||
internal class EditRequest(val folder: String, val openTarget: Boolean, val fromSummary: Boolean, val preview: EditPreview)
|
||||
|
||||
/**
|
||||
* What the editor shows on its first frame, from what the page already has (the game's row, or
|
||||
* the summary's); the rest (the ranked exes, where each piece of art came from) follows.
|
||||
* [exeName] / [startIn] null = not known yet.
|
||||
*/
|
||||
internal class EditPreview(
|
||||
val name: String, val exeName: String?, val startIn: String?, val launchOptions: String,
|
||||
val art: Map<AddedGameArt.Slot, File?>,
|
||||
) {
|
||||
companion object {
|
||||
/** From the Games tab's row for [game] and the few preferences the edits keep. */
|
||||
fun of(context: android.content.Context, game: com.droiddeck.launcher.frontend.Library.SteamGame, folder: String): EditPreview {
|
||||
val exe = SessionPrefs.addedGameExe(context, folder).ifEmpty {
|
||||
com.droiddeck.launcher.frontend.AddedExes.list(context).firstOrNull { it.folder == folder && it.picked }?.exe.orEmpty()
|
||||
}.takeIf { it.isNotEmpty() }?.let { File(it) }
|
||||
val startIn = SessionPrefs.addedGameStartIn(context, folder).takeIf { it.isNotEmpty() }?.substringAfterLast('/') ?: exe?.parentFile?.name
|
||||
return EditPreview(
|
||||
game.name, exe?.name, startIn, SessionPrefs.addedGameLaunch(context, folder),
|
||||
mapOf(AddedGameArt.Slot.COVER to game.art, AddedGameArt.Slot.BACKGROUND to game.hero, AddedGameArt.Slot.ICON to game.icon),
|
||||
)
|
||||
}
|
||||
|
||||
/** From a summary row's game and its cover. */
|
||||
fun of(game: AddedGames.Game, cover: File?) = EditPreview(
|
||||
game.name, game.exe.name, game.guestDir.substringAfterLast('/'), game.launchOptions, mapOf(AddedGameArt.Slot.COVER to cover),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small spinner where a value is still being read. */
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
androidx.compose.material3.CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
|
||||
/**
|
||||
* An added game's editor, Steam's non-Steam Properties in the app: Name, Target (the ranked exes,
|
||||
* "?" where nothing clearly won, Other exe…), Start in, Launch options, the four pieces of art, and
|
||||
* Remove (two presses; the files stay). Every change goes through the game's own Steam shortcut.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddedGameEditor(
|
||||
request: EditRequest, onBack: () -> Unit, onClose: () -> Unit, onChanged: () -> Unit,
|
||||
/** Remove pressed twice: the game's folder, its shortcut appid when known, its name. */
|
||||
onRemove: (folder: String, appId: Long?, name: String) -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val app = ctx.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
val folder = request.folder
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
var game by remember { mutableStateOf<AddedGames.Game?>(null) }
|
||||
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
|
||||
var ranked by remember { mutableStateOf<List<File>?>(null) }
|
||||
var targetOpen by remember { mutableStateOf(request.openTarget) }
|
||||
var armed by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<String?>(null) }
|
||||
var otherExe by remember { mutableStateOf(false) }
|
||||
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
|
||||
var unreachable by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(version) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val g = AddedGameEdits.game(app, folder)
|
||||
game = g
|
||||
if (g != null) {
|
||||
ranked = g.candidates.let { list -> if (list.any { it.path == g.exe.path }) list else listOf(g.exe) + list }
|
||||
arts = AddedGameArt.describe(app, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4_000); armed = false } }
|
||||
val change: (() -> Unit) -> Unit = { work ->
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { work() }
|
||||
version++
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
val preview = request.preview
|
||||
AppDialog(shown, close, "gameEditor", wide = false, maxWidth = 460.dp) {
|
||||
val g = game
|
||||
val name = g?.name ?: preview.name
|
||||
DialogTitleRow(
|
||||
name, onClose = close,
|
||||
leading = if (request.fromSummary) ({ SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground) { onBack(); close() } }) else null,
|
||||
)
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
FieldRow(stringResource(R.string.edit_name), focus = first, onClick = { editing = "name" }) { ValueText(name) }
|
||||
FieldRow(stringResource(R.string.edit_target), onClick = { targetOpen = !targetOpen }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val exeName = g?.exe?.name ?: preview.exeName
|
||||
Box(Modifier.weight(1f)) { if (exeName != null) ValueText(exeName) else Loading() }
|
||||
if (g == null && exeName != null) Loading()
|
||||
if (g?.exeUncertain == true) UncertainMark(stringResource(R.string.mode_added_exe_check))
|
||||
}
|
||||
}
|
||||
if (targetOpen) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(start = 16.dp)) {
|
||||
val list = ranked
|
||||
if (g == null || list == null) Box(Modifier.padding(8.dp)) { Loading() }
|
||||
else for (exe in list) key(exe.path) {
|
||||
val rel = exe.relativeToOrSelf(g.folder).invariantSeparatorsPath
|
||||
PickerRow(null, exe.name, rel.substringBeforeLast('/', "").ifEmpty { null }, accent = exe.path == g.exe.path) {
|
||||
targetOpen = false
|
||||
change { AddedGameEdits.setExe(app, folder, exe) }
|
||||
}
|
||||
}
|
||||
PickerRow(null, stringResource(R.string.edit_other_exe), null, enabled = g != null) { otherExe = true }
|
||||
}
|
||||
}
|
||||
FieldRow(stringResource(R.string.edit_start_in), onClick = { if (g != null) editing = "start" }) {
|
||||
val startIn = g?.guestDir?.substringAfterLast('/') ?: preview.startIn
|
||||
if (startIn != null) ValueText(startIn) else Loading()
|
||||
}
|
||||
val launch = g?.launchOptions ?: preview.launchOptions
|
||||
FieldRow(stringResource(R.string.edit_launch_options), onClick = { editing = "launch" }) { ValueText(launch.ifEmpty { "—" }, muted = launch.isEmpty()) }
|
||||
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 8.dp))
|
||||
for (slot in AddedGameArt.Slot.values()) key(slot) {
|
||||
val state = arts.firstOrNull { it.slot == slot }
|
||||
FieldRow(slot.label(), onClick = { if (g != null) artSlot = slot }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SlotThumb(slot, if (state != null) state.file else preview.art[slot])
|
||||
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoveRow(armed) {
|
||||
if (!armed) armed = true else {
|
||||
armed = false
|
||||
// At once: the page drops the game and the rest runs off this dialog's life.
|
||||
onRemove(folder, game?.appId, game?.name ?: preview.name)
|
||||
if (request.fromSummary) onBack()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unreachable) Text(stringResource(R.string.games_add_unreachable), fontSize = 12.sp, color = colors.error)
|
||||
}
|
||||
val shownName = game?.name ?: preview.name
|
||||
when (editing) {
|
||||
"name" -> TextEntryDialog(stringResource(R.string.edit_name), SessionPrefs.addedGameName(ctx, folder).ifEmpty { shownName }, onDismiss = { editing = null }) { value ->
|
||||
change { AddedGameEdits.setName(app, folder, if (value == shownName && SessionPrefs.addedGameName(app, folder).isEmpty()) "" else value) }
|
||||
}
|
||||
"launch" -> TextEntryDialog(stringResource(R.string.edit_launch_options), game?.launchOptions ?: preview.launchOptions, onDismiss = { editing = null }) { value ->
|
||||
change { AddedGameEdits.setLaunchOptions(app, folder, value) }
|
||||
}
|
||||
}
|
||||
val g = game ?: return
|
||||
when (editing) {
|
||||
"start" -> TextEntryDialog(stringResource(R.string.edit_start_in), hostPath(ctx, g), onDismiss = { editing = null }) { value ->
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { AddedGameEdits.setStartIn(app, folder, if (value == g.exe.parentFile?.path) "" else value) }
|
||||
unreachable = !ok
|
||||
version++
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (otherExe) ExePickerDialog(stringResource(R.string.games_pick_exe_title), start = g.folder, onDismiss = { otherExe = false }, onPick = { exe ->
|
||||
targetOpen = false
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { AddedGameEdits.setExe(app, folder, exe) }
|
||||
unreachable = !ok
|
||||
version++
|
||||
onChanged()
|
||||
}
|
||||
})
|
||||
artSlot?.let { slot ->
|
||||
val current = arts.firstOrNull { it.slot == slot }
|
||||
ArtChooserDialog(g, slot, current, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Start in as a path on the device: the override mapped back under the game's folder, else the exe's folder. */
|
||||
private fun hostPath(context: android.content.Context, game: AddedGames.Game): String {
|
||||
val folderGuest = AddedGames.guestPath(context, game.folder) ?: return game.exe.parentFile?.path.orEmpty()
|
||||
return when {
|
||||
game.guestDir == folderGuest -> game.folder.path
|
||||
game.guestDir.startsWith("$folderGuest/") -> game.folder.path + game.guestDir.removePrefix(folderGuest)
|
||||
else -> game.exe.parentFile?.path.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValueText(text: String, muted: Boolean = false) {
|
||||
Text(
|
||||
text, fontSize = 14.sp, color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Remove" in red; pressed once it asks, a second press within four seconds removes. */
|
||||
@Composable
|
||||
private fun RemoveRow(armed: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape12)
|
||||
.background(if (armed) colors.error.copy(alpha = 0.18f) else if (hot) colors.error.copy(alpha = 0.10f) else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.glideBorder(hot, Shape12, colors.error, colors.error.copy(alpha = 0.4f))
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(if (armed) R.string.mode_added_remove_confirm else R.string.edit_remove),
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of text in a compact dialog: [title] and a red X, the field, OK. */
|
||||
@Composable
|
||||
internal fun TextEntryDialog(title: String, initial: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var value by remember { mutableStateOf(initial) }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { focus } }
|
||||
AppDialog(shown, close, "textEntry", wide = false, maxWidth = 460.dp) {
|
||||
DialogTitleRow(title, onClose = close)
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value, { value = it }, singleLine = true, modifier = Modifier.fillMaxWidth().focusRequester(focus),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onSave(value.trim()); close() }),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Done),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
SecondaryButton(stringResource(R.string.common_ok), compact = true) { onSave(value.trim()); close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The art chooser for one [slot]: back, the slot's name and a red X; the images grouped by where
|
||||
* they come from (the game's folder, Steam, SteamGridDB, fetched as it opens), the current one
|
||||
* outlined, a tap applies it; "Pick image…" and "Reset to automatic".
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ArtChooserDialog(
|
||||
game: AddedGames.Game, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
|
||||
onBack: () -> Unit, onCloseAll: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val app = ctx.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onBack)
|
||||
val back = { shown.targetState = false }
|
||||
var options by remember { mutableStateOf<List<AddedGameArt.Option>?>(null) }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
val chosenRef = remember(current) { AddedGameArt.chosenSource(app, game, slot).second }
|
||||
LaunchedEffect(slot) { options = withContext(Dispatchers.IO) { runCatching { AddedGameArt.options(app, game, slot) }.getOrDefault(emptyList()) } }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
val apply: (AddedGameArt.Option) -> Unit = { option ->
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { AddedGameEdits.setArt(app, game.folder.path, slot, option) }
|
||||
onChanged()
|
||||
back()
|
||||
}
|
||||
}
|
||||
val slotName = slot.label()
|
||||
AppDialog(shown, back, "artChooser", wide = false, maxWidth = 460.dp) {
|
||||
DialogTitleRow(slotName, onClose = { onCloseAll() }, leading = {
|
||||
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
|
||||
})
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
val opts = options
|
||||
if (opts == null) {
|
||||
androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||
} else {
|
||||
var firstGiven = false
|
||||
for (group in listOf(AddedGameArt.FOLDER, AddedGameArt.STEAM, AddedGameArt.SGDB)) {
|
||||
val inGroup = opts.filter { it.group == group }
|
||||
if (inGroup.isEmpty()) continue
|
||||
Text(artSourceLabel(group), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (option in inGroup) key(option.full) {
|
||||
val focus = if (!firstGiven) first.also { firstGiven = true } else null
|
||||
ArtOption(slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = focus, description = "${artSourceLabel(group)} $slotName") { apply(option) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PickerRow(Icons.Outlined.Image, stringResource(R.string.art_pick_image), null, focus = if (opts?.isEmpty() != false) first else null) { picking = true }
|
||||
PickerRow(null, stringResource(R.string.art_reset), null) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { AddedGameEdits.resetArt(app, game.folder.path, slot) }
|
||||
onChanged()
|
||||
back()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (picking) ExePickerDialog(stringResource(R.string.art_pick_title, slotName), kind = PickKind.IMAGE, onDismiss = { picking = false }, onPick = { file ->
|
||||
apply(AddedGameArt.Option(AddedGameArt.FILE, file.path, file.path))
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtOption(slot: AddedGameArt.Slot, option: AddedGameArt.Option, selected: Boolean, focus: FocusRequester?, description: String, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val scale = when (slot) { AddedGameArt.Slot.COVER -> 1.9f; AddedGameArt.Slot.ICON -> 1.6f; else -> 1.5f }
|
||||
Box(
|
||||
Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(RoundedCornerShape(8.dp))
|
||||
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = description }
|
||||
.padding(2.dp),
|
||||
) {
|
||||
SlotThumb(slot, if (option.thumb.startsWith("https://")) option.thumb else File(option.thumb), scale, selected)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After "Add all games in this folder": how many were added and how many were there already, then
|
||||
* one row per added game (cover, name, exe, "?" where unsure); a row opens that game's editor.
|
||||
* [games] are the added games as the add left them, shown at once; the rows then follow edits and
|
||||
* removals, and the covers arrive.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddedGamesSummaryDialog(
|
||||
games: List<AddedGames.Game>, already: Int, checking: String? = null,
|
||||
onOpen: (folder: String, uncertain: Boolean, preview: EditPreview) -> Unit,
|
||||
onRemove: (folder: String, appId: Long?, name: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val app = ctx.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var rows by remember { mutableStateOf(games.map { it to (null as File?) }) }
|
||||
var gone by remember { mutableStateOf(emptySet<String>()) }
|
||||
var armed by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(armed) { if (armed != null) { kotlinx.coroutines.delay(4_000); armed = null } }
|
||||
// Rows show as the games arrive; each is refreshed once (current name and exe, a removed one
|
||||
// dropped) and gets its cover, without redoing the ones already done when more arrive.
|
||||
var resolved by remember { mutableStateOf(emptyMap<String, Pair<AddedGames.Game, File?>?>()) }
|
||||
LaunchedEffect(games) {
|
||||
for (old in games) {
|
||||
val path = old.folder.path
|
||||
if (path in resolved) continue
|
||||
val fresh = withContext(Dispatchers.IO) { AddedGames.single(app, path)?.let { g -> g to AddedGameArt.resolve(app, g).portrait } }
|
||||
resolved = resolved + (path to fresh)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(games, resolved) {
|
||||
rows = games.mapNotNull { g -> if (g.folder.path in resolved) resolved[g.folder.path] else g to null }
|
||||
}
|
||||
val first = remember { FocusRequester() }
|
||||
val list = rows.filter { it.first.folder.path !in gone }
|
||||
LaunchedEffect(list.isNotEmpty()) { if (list.isNotEmpty()) focusWithinFrames({ false }) { first } }
|
||||
AppDialog(shown, close, "addedSummary", wide = false, maxWidth = 440.dp) {
|
||||
DialogTitleRow(
|
||||
if (list.isEmpty() && checking == null) stringResource(R.string.games_added_none) else pluralStringResource(R.plurals.games_added_n, list.size, list.size),
|
||||
onClose = close,
|
||||
)
|
||||
if (already > 0) Text(pluralStringResource(R.plurals.games_already_n, already, already), fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
if (checking != null) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = colors.primary)
|
||||
Text(checking, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.7f).dp
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
list.forEachIndexed { i, (g, cover) ->
|
||||
key(g.folder.path) {
|
||||
ListRow(focus = if (i == 0) first else null, onClick = { onOpen(g.folder.path, g.exeUncertain, EditPreview.of(g, cover)); close() }) {
|
||||
SlotThumb(AddedGameArt.Slot.COVER, cover, 0.9f)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(g.exe.name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (g.exeUncertain) UncertainMark(stringResource(R.string.mode_added_exe_check))
|
||||
val path = g.folder.path
|
||||
RemoveX(armed == path, stringResource(R.string.summary_remove_game, g.name)) {
|
||||
if (armed != path) armed = path else {
|
||||
armed = null
|
||||
gone = gone + path
|
||||
onRemove(path, g.appId, g.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A summary row's red X: the first press arms it (filled), a second within 4 s removes the game. */
|
||||
@Composable
|
||||
private fun RemoveX(armed: Boolean, description: String, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(32.dp).clip(Shape12)
|
||||
.background(if (armed) colors.error else if (hot) colors.error.copy(alpha = 0.15f) else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.glideBorder(hot, Shape12, colors.error)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Close, null, tint = if (armed) colors.onError else colors.error, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class ControllerActions(
|
||||
val onSteamButton: (Boolean) -> Unit,
|
||||
val onQamButton: (Boolean) -> Unit,
|
||||
val onKeyboardButton: (Boolean) -> Unit,
|
||||
val onSelectSteam: (Boolean) -> Unit,
|
||||
val onEditLayout: () -> Unit,
|
||||
val onResetLayout: () -> Unit,
|
||||
val onMapping: () -> Unit,
|
||||
@@ -43,7 +44,7 @@ private fun Swatch(color: Int) {
|
||||
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
|
||||
ChoiceRow(
|
||||
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
|
||||
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
@@ -70,6 +71,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
|
||||
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
|
||||
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
|
||||
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
|
||||
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
|
||||
|
||||
@@ -15,10 +15,16 @@ import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
@@ -26,7 +32,8 @@ import kotlin.math.min
|
||||
*
|
||||
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
|
||||
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
|
||||
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
|
||||
* row beneath. Given the [pad] (the unfolded screen's panel), a fifth: a whole gamepad, each side
|
||||
* an analog stick or a trackpad as the player picks. A trackpad clicks from its bar or from a second finger while the first is down.
|
||||
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
|
||||
* where something is being touched.
|
||||
*/
|
||||
@@ -36,18 +43,29 @@ class DeckControlsPanel(
|
||||
private val onClose: () -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
) : LinearLayout(context) {
|
||||
private enum class Tab(val label: Int) { GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
|
||||
private val content = FrameLayout(context)
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
private val topBar: View by lazy { topBar() }
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
select(selected)
|
||||
select(if (pad != null) selectedWithPad else selected)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
// Held flat the gamepad has no Panels button, so another tab would have no way back:
|
||||
// turning the phone from upright onto its side returns to the gamepad.
|
||||
if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) {
|
||||
post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
@@ -65,6 +83,7 @@ class DeckControlsPanel(
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setPadding(dp(4), dp(4), dp(4), dp(4))
|
||||
for (tab in Tab.values()) {
|
||||
if (tab == Tab.GAMEPAD && pad == null) continue
|
||||
val view = TextView(context).apply {
|
||||
text = context.getString(tab.label)
|
||||
textSize = 14f
|
||||
@@ -88,7 +107,7 @@ class DeckControlsPanel(
|
||||
}
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
selected = tab
|
||||
if (pad != null) selectedWithPad = tab else selected = tab
|
||||
DeckControls.releaseAll()
|
||||
for ((each, view) in tabViews) {
|
||||
val on = each == tab
|
||||
@@ -96,12 +115,15 @@ class DeckControlsPanel(
|
||||
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
|
||||
}
|
||||
content.removeAllViews()
|
||||
// The gamepad needs every row of the panel; held upright its Panels button brings the tabs back.
|
||||
topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE
|
||||
content.addView(
|
||||
when (tab) {
|
||||
Tab.GRIPS -> gripsGrid()
|
||||
Tab.LEFT -> singlePad(right = false)
|
||||
Tab.RIGHT -> singlePad(right = true)
|
||||
Tab.BOTH -> bothPads()
|
||||
Tab.GAMEPAD -> gamepad(pad ?: return)
|
||||
},
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
|
||||
)
|
||||
@@ -151,6 +173,79 @@ class DeckControlsPanel(
|
||||
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole pad, placed for two thumbs rather than in columns (see [GamepadGeometry]): on each side
|
||||
* a surface where the thumb rests - most of play is on the sticks or trackpads - and a short
|
||||
* slide inward along its arc, the d-pad on the left and the face buttons on the right; the bumpers and triggers above, at the top of the arc; View, Steam, QAM and Menu in the
|
||||
* middle, which a thumb only reaches on purpose. Each surface is an analog stick or a Steam
|
||||
* trackpad, switched from its corner and remembered (SessionPrefs.gamepadSurface). Its tab bar
|
||||
* is hidden; held upright, a Panels button brings the other tabs back, and held flat there is no
|
||||
* room for one.
|
||||
*/
|
||||
private fun gamepad(pad: PadBridge): View = GamepadLayout(context).apply {
|
||||
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
|
||||
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
|
||||
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
|
||||
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
|
||||
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
|
||||
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
|
||||
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
|
||||
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
|
||||
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
|
||||
place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM)
|
||||
place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM)
|
||||
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
|
||||
place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
|
||||
GamepadGeometry.Slot.PANELS)
|
||||
}
|
||||
|
||||
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = 13f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = 0.08f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
/** One side's stick or trackpad, with a chip in its outer corner that swaps the two. */
|
||||
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
|
||||
val slot = this
|
||||
fun show() {
|
||||
slot.removeAllViews()
|
||||
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
|
||||
// No caption: the switch in its corner says which it is.
|
||||
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
val chip = TextView(context).apply {
|
||||
text = "\u21c4"
|
||||
contentDescription = context.getString(R.string.deck_surface_switch)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT_DIM)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
SessionPrefs.setGamepadSurface(context, right,
|
||||
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
|
||||
show()
|
||||
}
|
||||
}
|
||||
// In the corner away from the thumb, so play does not swap it by accident.
|
||||
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
|
||||
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
|
||||
TextView(context).apply {
|
||||
text = label
|
||||
@@ -262,7 +357,7 @@ class DeckControlsPanel(
|
||||
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
|
||||
* while a second finger is down.
|
||||
*/
|
||||
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
|
||||
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicked = false
|
||||
private var fingerX = 0f
|
||||
@@ -283,7 +378,7 @@ class DeckControlsPanel(
|
||||
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
|
||||
captionPaint.textSize = 12 * density
|
||||
captionPaint.color = if (touching) ACCENT else TEXT_DIM
|
||||
canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
@@ -333,9 +428,271 @@ class DeckControlsPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/** A pad button held while a finger is on it, written into the shared pad as the on-screen pad does. */
|
||||
private class PadButton(
|
||||
context: Context,
|
||||
private val pad: PadBridge,
|
||||
label: String,
|
||||
private val write: (PadState, Boolean) -> Unit,
|
||||
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
|
||||
override fun onDetachedFromWindow() {
|
||||
pad.applyTouch { write(it, false) }
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An analog stick that centres where the thumb lands, so it is never missed: the knob follows
|
||||
* the finger from there out to the ring. Tapping it again and holding (within [CLICK_MS]) is
|
||||
* the stick's click - L3 or R3 - for as long as the thumb stays down.
|
||||
*/
|
||||
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicking = false
|
||||
private var lastUp = 0L
|
||||
private var originX = 0f
|
||||
private var originY = 0f
|
||||
private var knobX = 0f
|
||||
private var knobY = 0f
|
||||
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private fun reach() = min(width, height) * 0.30f
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicking)
|
||||
val cx = if (touching) originX else width / 2f
|
||||
val cy = if (touching) originY else height / 2f
|
||||
ring.strokeWidth = 2 * density
|
||||
ring.color = if (touching) ACCENT else LINE
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val r = reach().coerceAtLeast(1f)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
// Centred where the thumb lands, but kept far enough in for the ring to fit.
|
||||
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
|
||||
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
|
||||
touching = true
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
|
||||
follow(event.x, event.y, r)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
touching = false
|
||||
lastUp = event.eventTime
|
||||
setClick(false)
|
||||
move(0f, 0f, 1f)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun follow(x: Float, y: Float, r: Float) {
|
||||
var dx = x - originX
|
||||
var dy = y - originY
|
||||
val length = hypot(dx, dy)
|
||||
if (length > r) { dx *= r / length; dy *= r / length }
|
||||
move(dx, dy, r)
|
||||
}
|
||||
|
||||
private fun move(dx: Float, dy: Float, r: Float) {
|
||||
knobX = dx
|
||||
knobY = dy
|
||||
pad.applyTouch { s ->
|
||||
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun setClick(down: Boolean) {
|
||||
if (clicking == down) return
|
||||
clicking = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
setClick(false)
|
||||
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** The d-pad: the direction is the finger's angle from the centre, diagonals included. */
|
||||
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0 // bits: 1 up, 2 right, 4 down, 8 left
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val size = min(width, height) * 0.86f
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val half = size / 2f
|
||||
val thick = size / 3f / 2f
|
||||
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
|
||||
arm.color = if (held and bit != 0) ACCENT else KNOB
|
||||
rect.set(l, t, r, b)
|
||||
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
|
||||
}
|
||||
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
|
||||
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
|
||||
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val bits = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.getX(0) - width / 2f
|
||||
val dy = event.getY(0) - height / 2f
|
||||
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
|
||||
// Eight sectors of 45 degrees, starting with right and going clockwise.
|
||||
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
|
||||
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
|
||||
}
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
set(bits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(bits: Int) {
|
||||
if (bits == held) return
|
||||
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
held = bits
|
||||
pad.applyTouch { s ->
|
||||
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(0)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** A, B, X and Y in a diamond, as on a Deck; several fingers can hold several at once. */
|
||||
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
|
||||
private var held = emptySet<Int>()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
/** Top, right, bottom, left - the centre of each, and the radius of all. */
|
||||
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val spread = min(width, height) * 0.30f
|
||||
val radius = min(width, height) * 0.16f
|
||||
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val (centres, radius) = layout()
|
||||
text.textSize = radius * 0.9f
|
||||
buttons.forEachIndexed { i, (button, label) ->
|
||||
val (x, y) = centres[i]
|
||||
val down = button in held
|
||||
fill.color = if (down) ACCENT else KNOB
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
text.color = if (down) ON_ACCENT else TEXT
|
||||
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val (centres, radius) = layout()
|
||||
val now = HashSet<Int>()
|
||||
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
|
||||
if (!ending) for (p in 0 until event.pointerCount) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
|
||||
val x = event.getX(p)
|
||||
val y = event.getY(p)
|
||||
// The nearest button within reach, so a thumb between two still lands on one.
|
||||
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
|
||||
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
|
||||
}
|
||||
set(now)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(now: Set<Int>) {
|
||||
if (now == held) return
|
||||
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
val before = held
|
||||
held = now
|
||||
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(emptySet())
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lays its controls out where [GamepadGeometry] puts them for the panel's size. */
|
||||
private class GamepadLayout(context: Context) : ViewGroup(context) {
|
||||
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
|
||||
|
||||
fun place(view: View, slot: GamepadGeometry.Slot) {
|
||||
slots[view] = slot
|
||||
addView(view)
|
||||
}
|
||||
|
||||
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(w, h)
|
||||
val boxes = boxes(w, h)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot]
|
||||
view.visibility = if (box == null) View.GONE else View.VISIBLE
|
||||
if (box == null) continue
|
||||
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val boxes = boxes(r - l, b - t)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot] ?: continue
|
||||
val left = box.left.toInt()
|
||||
val top = box.top.toInt()
|
||||
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A second press on a stick this soon after the last is its click (L3, R3). */
|
||||
private const val CLICK_MS = 300L
|
||||
|
||||
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
|
||||
private var selected = Tab.BOTH
|
||||
/** The same for the unfolded screen's panel, which opens on the gamepad. */
|
||||
private var selectedWithPad = Tab.GAMEPAD
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
@@ -348,5 +705,6 @@ class DeckControlsPanel(
|
||||
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
|
||||
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
|
||||
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
|
||||
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.SdCard
|
||||
import androidx.compose.material.icons.outlined.Smartphone
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
import java.io.File
|
||||
|
||||
/** What a picker lists besides folders. */
|
||||
internal enum class PickKind(val extensions: Set<String>) {
|
||||
EXE(setOf("exe")),
|
||||
IMAGE(setOf("png", "jpg", "jpeg", "webp", "ico")),
|
||||
}
|
||||
|
||||
/** A place a file can be picked from: internal storage or a card, at its root. */
|
||||
internal class PickVolume(val root: File, val label: String, val removable: Boolean) {
|
||||
val free: Long get() = runCatching { StatFs(root.path).availableBytes }.getOrDefault(0L)
|
||||
}
|
||||
|
||||
internal fun pickVolumes(context: Context): List<PickVolume> =
|
||||
listOf(PickVolume(Environment.getExternalStorageDirectory(), context.getString(R.string.games_internal), removable = false)) +
|
||||
GameStorage.options(context).mapNotNull { o ->
|
||||
File(o.path.substringBefore("/Android/")).takeIf { it.isDirectory }?.let { PickVolume(it, o.name, removable = true) }
|
||||
}.distinctBy { it.root.absolutePath }
|
||||
|
||||
/** A folder in a listing; [readable] false = shown greyed, not opened. */
|
||||
internal class PickFolder(val dir: File, val readable: Boolean)
|
||||
|
||||
internal class PickListing(val folders: List<PickFolder>, val files: List<File>)
|
||||
|
||||
/**
|
||||
* Every folder of [dir] (dot-folders left out, the unreadable ones marked), then the files [kind]
|
||||
* takes; each A-Z.
|
||||
*/
|
||||
internal fun pickListing(dir: File, kind: PickKind): PickListing {
|
||||
val all = dir.listFiles()?.filter { !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() }
|
||||
return PickListing(
|
||||
all.filter { it.isDirectory }.map { PickFolder(it, it.canRead() && it.list() != null) },
|
||||
all.filter { it.isFile && it.extension.lowercase() in kind.extensions },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact in-app file picker: [title] and a red X on one line; the storage rows (internal
|
||||
* storage, each card, free space); inside one, its root first, a breadcrumb, "..", every folder
|
||||
* and the [kind] files, scrolling in the dialog. Its width is what the first step needed and stays;
|
||||
* long names are cut short. B / Back goes up a folder, and closes from the storage rows. [start]
|
||||
* opens in a folder. [onAddAll] adds a row on top of a folder with subfolders.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ExePickerDialog(
|
||||
title: String, kind: PickKind = PickKind.EXE, start: File? = null,
|
||||
onAddAll: ((File) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
val volumes = remember { pickVolumes(ctx) }
|
||||
fun volumeOf(d: File) = volumes.filter { d.path == it.root.path || d.path.startsWith(it.root.path + "/") }.maxByOrNull { it.root.path.length }
|
||||
var dir by remember { mutableStateOf(start?.takeIf { it.isDirectory && volumeOf(it) != null }) }
|
||||
val volume = dir?.let { volumeOf(it) }
|
||||
val up = { dir = dir?.let { d -> if (volume == null || d.path == volume.root.path) null else d.parentFile } }
|
||||
val back = { if (dir == null) close() else up() }
|
||||
val first = remember(dir) { FocusRequester() }
|
||||
LaunchedEffect(dir) { focusWithinFrames({ false }) { first } }
|
||||
val listing = remember(dir) { dir?.let { pickListing(it, kind) } }
|
||||
var locked by remember { mutableStateOf<Dp?>(null) }
|
||||
AppDialog(
|
||||
shown, close, "filePicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back,
|
||||
modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } },
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
DialogTitleRow(title, onClose = close)
|
||||
if (dir != null && volume != null) {
|
||||
val crumbs = listOf(volume.label) + dir!!.path.removePrefix(volume.root.path).split('/').filter { it.isNotEmpty() }
|
||||
Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.7f).dp
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()).padding(top = 4.dp),
|
||||
) {
|
||||
if (dir == null) {
|
||||
volumes.forEachIndexed { i, v ->
|
||||
key(v.root.path) {
|
||||
PickerRow(if (v.removable) Icons.Outlined.SdCard else Icons.Outlined.Smartphone, v.label,
|
||||
stringResource(R.string.stores_target_free, formatBytes(v.free)), tall = true, focus = if (i == 0) first else null) { dir = v.root }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val shownListing = listing ?: PickListing(emptyList(), emptyList())
|
||||
PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up)
|
||||
if (onAddAll != null && shownListing.folders.any { it.readable }) {
|
||||
PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!); close() }
|
||||
}
|
||||
for (f in shownListing.folders) key(f.dir.path) {
|
||||
PickerRow(Icons.Outlined.Folder, f.dir.name, null, enabled = f.readable) { dir = f.dir }
|
||||
}
|
||||
for (f in shownListing.files) key(f.path) {
|
||||
PickerRow(if (kind == PickKind.IMAGE) Icons.Outlined.Image else Icons.Outlined.InsertDriveFile, f.name, formatBytes(f.length()), file = true) {
|
||||
onPick(f); close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact dialog's title line: [leading] (a back arrow), the title, a small red X. */
|
||||
@Composable
|
||||
internal fun DialogTitleRow(title: String, onClose: () -> Unit, leading: (@Composable () -> Unit)? = null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
leading?.invoke()
|
||||
Text(title, fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = MaterialTheme.colorScheme.error, onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
/** A 32 dp icon button for a dialog's title line. */
|
||||
@Composable
|
||||
internal fun SmallIconButton(icon: ImageVector, description: String, tint: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(32.dp).clip(Shape12)
|
||||
.background(if (hot) tint.copy(alpha = 0.15f) else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.glideBorder(hot, Shape12, tint)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
Icon(icon, null, tint = tint, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** One row of a compact dialog's list, the Install dialog's style: [content] laid out in a row. */
|
||||
@Composable
|
||||
internal fun ListRow(
|
||||
enabled: Boolean = true, accent: Boolean = false, tall: Boolean = false, focus: FocusRequester? = null,
|
||||
onClick: () -> Unit, content: @Composable androidx.compose.foundation.layout.RowScope.(hot: Boolean) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src) && enabled
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(Shape12)
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f))
|
||||
.glideBorder(hot, Shape12, pal.signal, if (accent) pal.signal else pal.line)
|
||||
.then(
|
||||
if (enabled) Modifier.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick) else Modifier,
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = if (tall) 11.dp else 9.dp),
|
||||
) { content(hot) }
|
||||
}
|
||||
|
||||
/** A list row with an icon, a name (cut short when long) and a detail on the right. */
|
||||
@Composable
|
||||
internal fun PickerRow(
|
||||
icon: ImageVector?, name: String, detail: String?, tall: Boolean = false, file: Boolean = false, accent: Boolean = false,
|
||||
enabled: Boolean = true, focus: FocusRequester? = null, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
ListRow(enabled, accent, tall, focus, onClick) { hot ->
|
||||
if (icon != null) Icon(icon, null, tint = if (hot || file || accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
Text(name, fontSize = 14.sp, fontWeight = if (tall) FontWeight.SemiBold else FontWeight.Normal, color = colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
@@ -424,11 +424,18 @@ private fun Content(
|
||||
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var confirmRemove by rememberSaveable { mutableStateOf(false) }
|
||||
if (confirmRemove) ConfirmDialog(
|
||||
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
|
||||
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
|
||||
)
|
||||
val actions: @Composable () -> Unit = {
|
||||
Actions {
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
|
||||
Cog(onClick = a.onDesktopSettings)
|
||||
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
|
||||
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -55,6 +59,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The Games page: the list, the hero for the selected game and its labels.
|
||||
|
||||
@@ -67,9 +72,15 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
|
||||
val narrow = LocalNarrowPane.current
|
||||
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
|
||||
GamesDialogs(a)
|
||||
val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true })
|
||||
val pageContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val onEdit: (Library.SteamGame) -> Unit = { g ->
|
||||
g.gameFiles?.let { GamesDialogState.editing = EditRequest(it.path, openTarget = false, fromSummary = false, preview = EditPreview.of(pageContext, g, it.path)) }
|
||||
}
|
||||
if (current == null) {
|
||||
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
|
||||
Rise(0) { PageHeader(stringResource(R.string.content_games)) { onAdd?.let { AddGameButton(it) } } }
|
||||
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
|
||||
if (!s.shortcutPicker) Rise(2) {
|
||||
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) }
|
||||
@@ -81,11 +92,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
val host = rememberMenuHost()
|
||||
if (games.size == 1) {
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
if (onAdd != null) Rise(0) { GamesHeader(1, onAdd, Modifier.padding(bottom = 10.dp)) }
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
|
||||
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
|
||||
GameActions(current, s, a)
|
||||
GameActions(current, s, a, onEdit)
|
||||
}
|
||||
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
|
||||
}
|
||||
@@ -98,7 +110,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
}
|
||||
val pal = LocalPalette.current
|
||||
Row(modifier = modifier) {
|
||||
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) },
|
||||
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) }, onAdd = onAdd,
|
||||
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
|
||||
Column(
|
||||
@@ -107,7 +119,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
) {
|
||||
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
|
||||
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
|
||||
GameActions(current, s, a)
|
||||
GameActions(current, s, a, onEdit)
|
||||
}
|
||||
SectionTitle(stringResource(R.string.games_launch_settings), null)
|
||||
LaunchSettings(s, a, host, current)
|
||||
@@ -135,7 +147,7 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) {
|
||||
|
||||
|
||||
@Composable
|
||||
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
|
||||
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit) {
|
||||
Actions {
|
||||
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
|
||||
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
|
||||
@@ -147,6 +159,8 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
|
||||
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
|
||||
}
|
||||
if (!s.shortcutPicker) GameShortcutMenu(g, a)
|
||||
// A game added in DroidDeck (Custom): its editor, Steam's Properties for a non-Steam game.
|
||||
if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) }
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
@@ -299,7 +313,7 @@ private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.c
|
||||
@Composable
|
||||
private fun GameList(
|
||||
games: List<Library.SteamGame>, current: Library.SteamGame,
|
||||
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier,
|
||||
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// Laid out whole, as the art grid is: the pad's focus search only finds rows that exist.
|
||||
@@ -307,16 +321,145 @@ private fun GameList(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
|
||||
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
|
||||
}
|
||||
GamesHeader(games.size, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
|
||||
// A game that arrives while the list is up (Steam installed it in the session that just
|
||||
// ended, or it was added) eases in; the ones there when the page opened are simply there.
|
||||
val seen = remember { HashSet<Int>() }
|
||||
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
for (g in games) key(g.appId) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
Box(Modifier.arriveIn(settled && g.appId !in seen)) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
}
|
||||
}
|
||||
androidx.compose.runtime.SideEffect {
|
||||
seen.addAll(games.map { it.appId })
|
||||
if (games.isNotEmpty()) settled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Games N", and the + that adds a game from its .exe beside it ([onAdd] null: no +). */
|
||||
@Composable
|
||||
private fun GamesHeader(count: Int, onAdd: (() -> Unit)?, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier) {
|
||||
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
Text(count.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
if (onAdd != null) AddGameButton(onAdd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games tab's dialogs, one at a time: the + picker, the summary after "Add all games in this
|
||||
* folder", and a game's editor (from the page's ✎ or a summary row, with back to the summary).
|
||||
*/
|
||||
internal object GamesDialogState {
|
||||
var adding by androidx.compose.runtime.mutableStateOf(false)
|
||||
var summary by androidx.compose.runtime.mutableStateOf<Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>?>(null)
|
||||
var editing by androidx.compose.runtime.mutableStateOf<EditRequest?>(null)
|
||||
/** The subfolder "Add all games in this folder" is looking at; null when it is done. */
|
||||
var checking by androidx.compose.runtime.mutableStateOf<String?>(null)
|
||||
/** The summary an editor opened from it goes back to. */
|
||||
var behindEditor: Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>? = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GamesDialogs(a: FrontEndActions) {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
val st = GamesDialogState
|
||||
if (st.adding) ExePickerDialog(
|
||||
stringResource(R.string.games_add_title),
|
||||
onAddAll = { dir ->
|
||||
st.summary = emptyList<com.droiddeck.launcher.frontend.AddedGames.Game>() to 0
|
||||
st.checking = dir.name
|
||||
scope.launch {
|
||||
val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
com.droiddeck.launcher.frontend.AddedGames.addFolder(
|
||||
ctx.applicationContext, dir,
|
||||
onGame = { g -> scope.launch { st.summary?.let { (list, n) -> st.summary = (list + g) to n } } },
|
||||
onChecking = { name -> scope.launch { st.checking = name } },
|
||||
)
|
||||
}
|
||||
st.checking = null
|
||||
a.onAddedGamesChanged(result.added.map { it.folder.path })
|
||||
st.summary?.let { (list, _) -> st.summary = list to result.already }
|
||||
}
|
||||
},
|
||||
onPick = { a.onAddGameExe(it.path) },
|
||||
onDismiss = { st.adding = false },
|
||||
)
|
||||
st.summary?.let { (added, already) ->
|
||||
AddedGamesSummaryDialog(added, already, checking = st.checking, onOpen = { folder, uncertain, preview ->
|
||||
st.behindEditor = added to already
|
||||
st.editing = EditRequest(folder, openTarget = uncertain, fromSummary = true, preview = preview)
|
||||
}, onRemove = { folder, appId, name ->
|
||||
st.summary = st.summary?.let { (list, n) -> list.filterNot { it.folder.path == folder } to n }
|
||||
a.onAddedGameRemoved(folder, appId, name)
|
||||
}, onDismiss = { st.summary = null; st.checking = null })
|
||||
}
|
||||
st.editing?.let { request ->
|
||||
AddedGameEditor(
|
||||
request,
|
||||
onBack = { st.behindEditor?.let { st.summary = it }; st.behindEditor = null },
|
||||
onClose = { st.editing = null },
|
||||
onChanged = { a.onAddedGamesChanged(listOf(request.folder)) },
|
||||
onRemove = { folder, appId, name -> a.onAddedGameRemoved(folder, appId, name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The ✎ on a Custom game's page: icon only. */
|
||||
@Composable
|
||||
private fun EditGameButton(onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val label = stringResource(R.string.common_edit)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("game:edit").size(40.dp).clip(Shape12)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.55f))
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = label },
|
||||
) {
|
||||
androidx.compose.material3.Icon(Icons.Outlined.Edit, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** A round + that opens the .exe picker. */
|
||||
@Composable
|
||||
private fun AddGameButton(onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val focused by src.collectIsFocusedAsState()
|
||||
val hovered by src.collectIsHoveredAsState()
|
||||
val label = stringResource(R.string.games_add)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("games:add").size(32.dp).clip(CircleShape)
|
||||
.background(if (focused || hovered) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)
|
||||
.glideBorder(focused, CircleShape, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = label },
|
||||
) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Add, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Fades and slides a row in once when [arriving]; nothing for one already there (or with animations off). */
|
||||
@Composable
|
||||
internal fun Modifier.arriveIn(arriving: Boolean): Modifier {
|
||||
val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) }
|
||||
LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) }
|
||||
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() }
|
||||
}
|
||||
|
||||
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
|
||||
@Composable
|
||||
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
|
||||
|
||||
@@ -33,13 +33,16 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -86,6 +89,7 @@ class FrontEndState(
|
||||
val stage: String,
|
||||
val percent: Int,
|
||||
val desktopInstalled: Boolean,
|
||||
val desktopPresent: Boolean = desktopInstalled,
|
||||
val offlineAccount: String?,
|
||||
val offline: Boolean,
|
||||
val frameGenLabel: String,
|
||||
@@ -125,6 +129,7 @@ class FrontEndState(
|
||||
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
|
||||
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
|
||||
val showPhantomGate: Boolean = false,
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
val launcherFullscreen: Boolean = true,
|
||||
val animationsEnabled: Boolean = true,
|
||||
/** The Flathub Store, a beta the user turns on in Setup. */
|
||||
@@ -137,6 +142,9 @@ class FrontEndState(
|
||||
val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY,
|
||||
/** A page to open once: "stores:downloads", or a selection such as "app:<id>" (a notification's tap). */
|
||||
val navigate: String? = null,
|
||||
/** Setup: art for added games is fetched; the user has a SteamGridDB key of their own. */
|
||||
val addedGamesArt: Boolean = true,
|
||||
val sgdbUserKey: Boolean = false,
|
||||
val storesShowMature: Boolean = false,
|
||||
/** Downloads queued, running or paused across the three stores; the rail item's badge. */
|
||||
val storeDownloadsActive: Int = 0,
|
||||
@@ -151,6 +159,15 @@ class FrontEndActions(
|
||||
val onGameShortcut: (Library.SteamGame) -> Unit = {},
|
||||
val onExportGameFile: (Library.SteamGame) -> Unit = {},
|
||||
val onSyncGameFiles: () -> Unit = {},
|
||||
/** The Games tab's +: the .exe picked in its dialog, to add as a game. */
|
||||
val onAddGameExe: (path: String) -> Unit = {},
|
||||
/** These added games (by folder) were added or edited: the Games tab updates just them. */
|
||||
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
|
||||
/** An added game removed in its editor: off the Games tab at once, out of Steam after. */
|
||||
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
|
||||
/** Setup's Artwork switch and the user's SteamGridDB API key ("" removes it). */
|
||||
val onAddedGamesArt: (Boolean) -> Unit = {},
|
||||
val onSgdbKey: (String) -> Unit = {},
|
||||
val onStopGameFileSync: () -> Unit = {},
|
||||
val onCopyGameLink: (Library.SteamGame) -> Unit = {},
|
||||
val onDesktop: () -> Unit,
|
||||
@@ -163,6 +180,7 @@ class FrontEndActions(
|
||||
val onResume: () -> Unit,
|
||||
val onSteamSettings: () -> Unit,
|
||||
val onDesktopSettings: () -> Unit,
|
||||
val onRemoveDesktop: () -> Unit = {},
|
||||
val onInstallPackage: (String) -> Unit,
|
||||
val onRemovePackage: (String) -> Unit,
|
||||
val onRuntime: () -> Unit,
|
||||
@@ -187,6 +205,7 @@ class FrontEndActions(
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onLanguage: (String) -> Unit = {},
|
||||
val onAppScale: (Int) -> Unit = {},
|
||||
val onOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
@@ -454,7 +473,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
var anyFocused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(colors.background)
|
||||
.then(if (s.launcherFullscreen) Modifier else Modifier.systemBarsPadding())
|
||||
.windowInsetsPadding(if (s.launcherFullscreen) WindowInsets.displayCutout else WindowInsets.systemBars)
|
||||
.onFocusChanged { anyFocused = it.hasFocus },
|
||||
) {
|
||||
// Start controllers on the current page's main action, else on the rail.
|
||||
|
||||
@@ -164,6 +164,8 @@ internal fun SetupPanel(
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
var editSgdbKey by remember { mutableStateOf(false) }
|
||||
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
@@ -349,6 +351,11 @@ internal fun SetupPanel(
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_orientation), null,
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.orientation, onPick = a.onOrientation,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
@@ -368,6 +375,13 @@ internal fun SetupPanel(
|
||||
s.storeEnabled,
|
||||
) { a.onStoreEnabled(it) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.content_games)) {
|
||||
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
|
||||
ActionRow(
|
||||
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
|
||||
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_stores)) {
|
||||
ToggleRow(
|
||||
host, "stores-enabled", stringResource(R.string.setup_stores_show),
|
||||
@@ -496,6 +510,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
|
||||
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
|
||||
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
|
||||
game = game,
|
||||
) { wincompOpen = null }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Where the unfolded screen's gamepad puts each control, for a panel of a given size: plain
|
||||
* numbers, so the layout is tested without a device (GamepadGeometryTest).
|
||||
*
|
||||
* Each thumb pivots at its bottom corner; its main control (a stick or trackpad) sits where it
|
||||
* rests, diagonally in from the corner, and the right side mirrors the left.
|
||||
*
|
||||
* A wide strip (a foldable held flat as a tablet): the second control - the d-pad, the face
|
||||
* buttons - a slide inward along the arc, the shoulders above, and View, Steam, QAM and Menu
|
||||
* between the arcs. The reach R is the strip's height, or a third of its width.
|
||||
*
|
||||
* A tall panel (held upright): the second control a slide up the arc instead, the shoulders above
|
||||
* that, View, Steam, QAM and Menu between the two sticks, and Panels - the Deck controls' other
|
||||
* tabs - between the shoulders. Each side's column S is half the width, or as much as the height
|
||||
* holds. A wide strip has no room for Panels and leaves it out.
|
||||
*/
|
||||
object GamepadGeometry {
|
||||
enum class Slot {
|
||||
LEFT_MAIN, LEFT_SECOND, RIGHT_MAIN, RIGHT_SECOND,
|
||||
LEFT_OUTER_SHOULDER, LEFT_INNER_SHOULDER, RIGHT_INNER_SHOULDER, RIGHT_OUTER_SHOULDER,
|
||||
VIEW, STEAM, QAM, MENU,
|
||||
/** The Deck controls' other tabs: on a tall panel only. */
|
||||
PANELS,
|
||||
}
|
||||
|
||||
/** A control's box: its centre and size, in pixels. */
|
||||
data class Box(val cx: Float, val cy: Float, val width: Float, val height: Float) {
|
||||
val left get() = cx - width / 2f
|
||||
val top get() = cy - height / 2f
|
||||
val right get() = cx + width / 2f
|
||||
val bottom get() = cy + height / 2f
|
||||
|
||||
fun overlaps(other: Box): Boolean =
|
||||
left < other.right && other.left < right && top < other.bottom && other.top < bottom
|
||||
}
|
||||
|
||||
/** The middle buttons, in a row in this order; two by two as on a Deck, View and Menu above Steam and QAM. */
|
||||
private val middle = listOf(Slot.VIEW, Slot.STEAM, Slot.QAM, Slot.MENU)
|
||||
private val twoByTwo = mapOf(Slot.VIEW to 0, Slot.MENU to 1, Slot.STEAM to 2, Slot.QAM to 3)
|
||||
|
||||
/** A panel this shape is laid out as a tall panel (held upright), which has room for Panels;
|
||||
* anything flatter is a wide strip. */
|
||||
fun tall(w: Float, h: Float): Boolean = h >= w * 0.45f
|
||||
|
||||
fun layout(w: Float, h: Float, density: Float): Map<Slot, Box> {
|
||||
if (w <= 0f || h <= 0f) return emptyMap()
|
||||
val pad = 8 * density
|
||||
val buttonH = 40 * density
|
||||
// A row needs its buttons comfortable; two by two will take them a little narrower.
|
||||
val rowButton = 64 * density
|
||||
val gridButton = 48 * density
|
||||
val gutter = 8 * density
|
||||
val boxes = LinkedHashMap<Slot, Box>()
|
||||
fun mirror(x: Float, right: Boolean) = if (right) w - x else x
|
||||
fun pair(left: Slot, right: Slot, x: Float, cy: Float, bw: Float, bh: Float) {
|
||||
boxes[left] = Box(mirror(x, false), cy, bw, bh)
|
||||
boxes[right] = Box(mirror(x, true), cy, bw, bh)
|
||||
}
|
||||
fun middleGrid(columns: Int, span: Float, top: Float) {
|
||||
val each = (span / columns).coerceAtMost(110 * density)
|
||||
for ((order, slot) in middle.withIndex()) {
|
||||
val index = if (columns == 2) twoByTwo.getValue(slot) else order
|
||||
val x = w / 2f + (index % columns - (columns - 1) / 2f) * each
|
||||
boxes[slot] = Box(x, top + buttonH / 2f + (index / columns) * (buttonH + 10 * density), each - gutter, buttonH)
|
||||
}
|
||||
}
|
||||
if (!tall(w, h)) {
|
||||
val reach = min(h - pad, w * 0.33f)
|
||||
val main = reach * 0.66f
|
||||
val second = reach * 0.54f
|
||||
val mainTop = h - reach * 0.40f - main / 2f
|
||||
val shoulderW = reach * 0.36f
|
||||
val shoulderH = (reach * 0.16f).coerceAtLeast(buttonH)
|
||||
// Above the main controls, never over them, and inside the panel.
|
||||
val shoulderY = min(h - reach * 0.94f + shoulderH / 2f, mainTop - gutter - shoulderH / 2f)
|
||||
.coerceAtLeast(pad + shoulderH / 2f)
|
||||
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, reach * 0.40f, h - reach * 0.40f, main, main)
|
||||
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, reach * 1.03f, h - reach * 0.30f, second, second)
|
||||
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, reach * 0.22f, shoulderY, shoulderW, shoulderH)
|
||||
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, reach * 0.62f, shoulderY, shoulderW, shoulderH)
|
||||
// Between the arcs: in a row if they fit, else two by two, else one above another.
|
||||
val gap = w - 2 * reach * 1.32f
|
||||
val columns = when {
|
||||
gap >= middle.size * rowButton -> middle.size
|
||||
gap >= 2 * gridButton -> 2
|
||||
else -> 1
|
||||
}
|
||||
middleGrid(columns, gap - 2 * gutter, shoulderY - buttonH / 2f)
|
||||
} else {
|
||||
val side = min(w * 0.5f, h * 0.70f)
|
||||
val main = side * 0.62f
|
||||
val second = side * 0.46f
|
||||
val secondCy = h - side * 0.97f
|
||||
val shoulderW = side * 0.34f
|
||||
val shoulderH = (side * 0.13f).coerceAtLeast(buttonH)
|
||||
val shoulderY = (secondCy - second / 2f - gutter - shoulderH / 2f).coerceAtLeast(pad + shoulderH / 2f)
|
||||
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, side * 0.40f, h - side * 0.40f, main, main)
|
||||
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, side * 0.74f, secondCy, second, second)
|
||||
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, side * 0.20f, shoulderY, shoulderW, shoulderH)
|
||||
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, side * 0.57f, shoulderY, shoulderW, shoulderH)
|
||||
// Between the two sticks at the bottom: two by two, or one above another.
|
||||
val gap = w - 2 * (side * 0.40f + main / 2f)
|
||||
val columns = if (gap >= 2 * gridButton) 2 else 1
|
||||
val rows = (middle.size + columns - 1) / columns
|
||||
middleGrid(columns, gap - 2 * gutter, h - pad - rows * buttonH - (rows - 1) * 10 * density)
|
||||
// Between the inner shoulders, where it fits.
|
||||
val panelsW = min(112 * density, w - 2 * (side * 0.57f + shoulderW / 2f) - 2 * gutter)
|
||||
if (panelsW >= buttonH) boxes[Slot.PANELS] = Box(w / 2f, shoulderY, panelsW, buttonH)
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
}
|
||||
@@ -86,15 +86,13 @@ class ModeSettings(
|
||||
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
|
||||
val syncBackend: String? = null,
|
||||
val stretch16x9: Boolean? = null,
|
||||
/** Steam only: the client branch forced on the command line. */
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: enable the SteamOS client interface and its performance controls. */
|
||||
val steamDeckMode: Boolean = false,
|
||||
val steamRepairQueued: Boolean = false,
|
||||
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
|
||||
/** Steam: the QAM's performance overlay (mangoapp) is started. */
|
||||
val mangoapp: Boolean = true,
|
||||
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
|
||||
val steamController: String? = null,
|
||||
/** Unfolded controls (Steam only; null hides the row). */
|
||||
val unfoldedControls: Boolean? = null,
|
||||
/** Steam only: start a Steam session when DroidDeck opens. */
|
||||
val runSteamAtStartup: Boolean = false,
|
||||
/** Null outside Steam; the saved choice is separate from Android's access and device switch. */
|
||||
@@ -103,10 +101,6 @@ class ModeSettings(
|
||||
val wifiDiscoveryLocation: Boolean = false,
|
||||
val wifiDiscoveryAsked: Boolean = false,
|
||||
val wifiDiscoveryBlocked: Boolean = false,
|
||||
/** Steam only: the user's chosen Games folders; null outside Steam. */
|
||||
val addedGamesDirs: List<String>? = null,
|
||||
val addedGames: List<AddedGameRow> = emptyList(),
|
||||
val addedGamesArt: Boolean = true,
|
||||
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
|
||||
/** Steam only: Decky Loader is managed from the Steam session settings. */
|
||||
val deckyInstalled: String? = null,
|
||||
@@ -120,9 +114,6 @@ class ModeSettings(
|
||||
val deckySessionRunning: Boolean = false,
|
||||
)
|
||||
|
||||
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
|
||||
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
|
||||
|
||||
class ModeSettingsActions(
|
||||
val onResolution: (String) -> Unit,
|
||||
val onHdr: (Boolean) -> Unit,
|
||||
@@ -146,19 +137,13 @@ class ModeSettingsActions(
|
||||
val onForceSsbs: (Boolean) -> Unit = {},
|
||||
val onSyncBackend: (String) -> Unit = {},
|
||||
val onStretch16x9: (Boolean) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onSteamRepair: () -> Unit = {},
|
||||
val onSteamDeckMode: (Boolean) -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
val onUnfoldedControls: (Boolean) -> Unit = {},
|
||||
val onRunSteamAtStartup: (Boolean) -> Unit = {},
|
||||
val onWifiDiscovery: (Boolean) -> Unit = {},
|
||||
val onWifiDiscoverySettings: () -> Unit = {},
|
||||
val onPickAddedGamesDir: () -> Unit = {},
|
||||
val onForgetAddedGamesDir: (path: String) -> Unit = {},
|
||||
val onAddedGamesArt: (Boolean) -> Unit = {},
|
||||
val onAddedGameExe: (folderPath: String, path: String) -> Unit = { _, _ -> },
|
||||
val onPickAddedGameExe: (folderPath: String) -> Unit = {},
|
||||
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
|
||||
val onDeckyCheck: () -> Unit = {},
|
||||
val onDeckyEnabled: (Boolean) -> Unit = {},
|
||||
@@ -268,6 +253,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
@@ -277,11 +263,19 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint),
|
||||
s.unfoldedControls, onChange = a.onUnfoldedControls,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
@@ -384,25 +378,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "steamdeck", stringResource(R.string.mode_deck_mode),
|
||||
stringResource(R.string.mode_deck_mode_hint),
|
||||
s.steamDeckMode, onChange = a.onSteamDeckMode,
|
||||
)
|
||||
if (s.steamDeckMode) ToggleRow(
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
|
||||
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
|
||||
else ChoiceRow(
|
||||
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
|
||||
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
|
||||
note = stringResource(R.string.mode_branch_note),
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
@@ -429,33 +410,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
|
||||
for (dir in s.addedGamesDirs) {
|
||||
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
|
||||
ActionRow(
|
||||
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) stringResource(R.string.mode_added_none) else pluralStringResource(R.plurals.mode_added_count, n, n)) + ". " + stringResource(R.string.added_games_forget_hint),
|
||||
stringResource(R.string.common_forget), onClick = { a.onForgetAddedGamesDir(dir) },
|
||||
)
|
||||
}
|
||||
ActionRow(
|
||||
if (s.addedGamesDirs.isEmpty()) stringResource(R.string.mode_games_folder) else stringResource(R.string.mode_games_folder_another),
|
||||
stringResource(R.string.added_games_import_hint),
|
||||
stringResource(R.string.common_add_ellipsis), onClick = a.onPickAddedGamesDir,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "addedArt", stringResource(R.string.mode_added_art),
|
||||
stringResource(R.string.mode_added_art_hint),
|
||||
s.addedGamesArt, onChange = a.onAddedGamesArt,
|
||||
)
|
||||
for (g in s.addedGames) {
|
||||
ChoiceRow(
|
||||
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
|
||||
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
|
||||
note = stringResource(R.string.mode_added_exe_note),
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (s.syncBackend != null) SettingsRow(
|
||||
stringResource(R.string.sync_backend_title),
|
||||
@@ -552,6 +506,31 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
|
||||
/** Width × height for the session, with the common handheld shapes one tap away. */
|
||||
/** Entry for the user's SteamGridDB API key; the saved key is never shown back. */
|
||||
@Composable
|
||||
internal fun SgdbKeyDialog(hasKey: Boolean, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.mode_sgdb_key)) },
|
||||
text = {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
key, { v -> key = v.trim() }, label = { Text(stringResource(R.string.mode_sgdb_key)) },
|
||||
singleLine = true, visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = { androidx.compose.material3.TextButton(onClick = { onSave(key) }, enabled = key.isNotEmpty()) { Text(stringResource(R.string.common_ok)) } },
|
||||
dismissButton = {
|
||||
androidx.compose.foundation.layout.Row {
|
||||
if (hasKey) androidx.compose.material3.TextButton(onClick = { onSave("") }) { Text(stringResource(R.string.common_delete)) }
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
|
||||
|
||||
@@ -194,6 +194,9 @@ class DrawerActions(
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val backActionsInverted: Boolean,
|
||||
/** What the pad is to Steam (SessionPrefs.CONTROLLER_*), switched live; null hides the row. */
|
||||
val padType: String? = null,
|
||||
val onPadType: (String) -> Unit = {},
|
||||
val touchMode: String,
|
||||
val touchAuto: String,
|
||||
val fexPreset: String,
|
||||
@@ -217,6 +220,8 @@ class DrawerActions(
|
||||
val onSteamMenu: (() -> Unit)?,
|
||||
val onQam: (() -> Unit)?,
|
||||
val onOsc: (String) -> Unit,
|
||||
/** Edits the Steam touch layout of the game in front, when Steam's controls are up. */
|
||||
val onEditSteamTouch: (() -> Unit)? = null,
|
||||
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
|
||||
val onRumble: (Boolean) -> Unit = {},
|
||||
val onSteamButton: (Boolean) -> Unit = {},
|
||||
@@ -240,6 +245,9 @@ class DrawerActions(
|
||||
val pipAutoEnter: Boolean = false,
|
||||
val onPip: () -> Unit = {},
|
||||
val onPipAutoEnter: (Boolean) -> Unit = {},
|
||||
/** Unfolded controls (on a foldable; null hides the row). */
|
||||
val unfoldedControls: Boolean? = null,
|
||||
val onUnfoldedControls: (Boolean) -> Unit = {},
|
||||
val onShareLogs: () -> Unit,
|
||||
val onStop: () -> Unit,
|
||||
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
|
||||
@@ -414,13 +422,27 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
}
|
||||
SessionDrawerPage.CONTROLLER -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
|
||||
a.padType?.let { type ->
|
||||
ChoiceRow(host, "pad-type", stringResource(R.string.mode_controller), null,
|
||||
listOf(SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360)),
|
||||
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
|
||||
}
|
||||
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
|
||||
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
|
||||
// Steam's touch controller is Steam's alone; elsewhere that choice behaves as Auto.
|
||||
if (!a.steam && a.oscMode == SessionPrefs.OSC_STEAM_TOUCH) SessionPrefs.OSC_AUTO else a.oscMode,
|
||||
chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
|
||||
a.onEditSteamTouch?.let { edit ->
|
||||
ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit)
|
||||
}
|
||||
a.controller?.let { c ->
|
||||
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
|
||||
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
|
||||
@@ -438,6 +460,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
|
||||
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
|
||||
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
|
||||
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
|
||||
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user