mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
Merge remote-tracking branch 'origin/main' into pr1067
This commit is contained in:
commit
1ee597e07f
538 files changed
+38465
-3751
No files matched your search
@@ -23,7 +23,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Build APK
|
||||
name: Validate source and build result
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
+312
-459
@@ -16,26 +16,29 @@ on:
|
||||
- 'LICENSE'
|
||||
- '.gitignore'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rebuild_preloads:
|
||||
description: Rebuild preloads instead of reusing the approved binary snapshot
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
PRELOAD_CACHE_VERSION: v6
|
||||
|
||||
jobs:
|
||||
xbox:
|
||||
name: Xbox ARM64 backend
|
||||
source:
|
||||
name: Pin source tree
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
base-sha: ${{ steps.merge.outputs.base-sha }}
|
||||
base-sha: ${{ steps.merge.outputs.base-sha || steps.trusted.outputs.base-sha }}
|
||||
tree-sha: ${{ steps.source.outputs.tree-sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -53,9 +56,46 @@ jobs:
|
||||
git config user.name "ci"
|
||||
git config user.email "ci@users.noreply.github.com"
|
||||
git merge --no-ff --no-edit origin/main
|
||||
- name: Record backend source tree
|
||||
- name: Resolve trusted manifest base
|
||||
id: trusted
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
|
||||
- name: Record pinned source tree
|
||||
id: source
|
||||
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
xbox:
|
||||
name: Xbox ARM64 backend
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Merge the pinned APK base
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
git config user.email "ci@users.noreply.github.com"
|
||||
git merge --no-ff --no-edit FETCH_HEAD
|
||||
- name: Verify pinned source tree
|
||||
env:
|
||||
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
||||
run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE"
|
||||
- name: Plan component identities
|
||||
id: plan
|
||||
run: python3 tools/components/prepare.py plan
|
||||
- uses: actions/cache@v4
|
||||
id: xbox-cache
|
||||
with:
|
||||
@@ -63,14 +103,10 @@ jobs:
|
||||
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
app/build/xbox/xodus-droiddeck-source.tar.xz
|
||||
app/build/xbox/runtime/*-source.tar.xz
|
||||
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
|
||||
key: xbox-v2-${{ steps.plan.outputs.xbox }}
|
||||
- name: Build Xbox backend
|
||||
if: steps.xbox-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rustup toolchain install 1.99.0 --profile minimal
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
|
||||
bash tools/xbox/build.sh
|
||||
run: python3 tools/components/prepare.py xbox
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xbox-arm64
|
||||
@@ -85,9 +121,158 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
components:
|
||||
name: Prepare verified components
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Merge the pinned APK base
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
git config user.email "ci@users.noreply.github.com"
|
||||
git merge --no-ff --no-edit FETCH_HEAD
|
||||
- name: Verify pinned source tree
|
||||
env:
|
||||
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
||||
run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE"
|
||||
- name: Resolve the pinned Android NDK
|
||||
run: bash tools/ci/resolve-ndk.sh
|
||||
|
||||
- name: Plan component identities
|
||||
id: plan
|
||||
run: python3 tools/components/prepare.py plan
|
||||
- name: Cache remaining Linux runtime assets
|
||||
id: preloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
app/src/main/assets/linuxfs
|
||||
!app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet
|
||||
!app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
!app/src/main/assets/linuxfs/i386/libblsession.so
|
||||
!app/src/main/assets/linuxfs/i386/libfakeinput.so
|
||||
!app/src/main/assets/linuxfs/i386/libfaultreport.so
|
||||
!app/src/main/assets/linuxfs/libblaudit.so
|
||||
!app/src/main/assets/linuxfs/libblfastpath.so
|
||||
!app/src/main/assets/linuxfs/libblsession.so
|
||||
!app/src/main/assets/linuxfs/libfakeinput.so
|
||||
!app/src/main/assets/linuxfs/libssbs.so
|
||||
!app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard
|
||||
!app/src/main/assets/linuxfs/x86_64/libblsession.so
|
||||
!app/src/main/assets/linuxfs/x86_64/libfakeinput.so
|
||||
!app/src/main/assets/linuxfs/x86_64/libfaultreport.so
|
||||
!app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so
|
||||
!app/src/main/assets/linuxfs/x86_64/libwebkitinput.so
|
||||
key: runtime-v9-${{ steps.plan.outputs.runtime }}
|
||||
|
||||
- name: Cache PulseAudio 13.0 source
|
||||
id: pa-src
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: pa13
|
||||
key: pulseaudio-src-${{ steps.plan.outputs.pulseaudio-source }}
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
key: proot-v2-${{ steps.plan.outputs.proot }}
|
||||
- name: Cache GOG Galaxy companion
|
||||
id: comet
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet
|
||||
key: comet-v2-${{ steps.plan.outputs.comet }}
|
||||
|
||||
- name: Restore verified preload snapshots
|
||||
id: component-preloads
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FORCE_PRELOAD_REBUILD: ${{ inputs.rebuild_preloads || 'false' }}
|
||||
COMPONENT_TRUSTED_BASE: ${{ needs.source.outputs.base-sha }}
|
||||
run: python3 tools/components/prepare.py preload-snapshot
|
||||
|
||||
- name: Prepare required Linux build tools once
|
||||
env:
|
||||
COMPONENTS_TO_BUILD: >-
|
||||
${{ steps.preloads.outputs.cache-hit != 'true' && 'runtime' || '' }}
|
||||
${{ steps.component-preloads.outputs.arm64 != 'true' && 'arm64-preloads' || '' }}
|
||||
${{ steps.comet.outputs.cache-hit != 'true' && 'comet' || '' }}
|
||||
run: python3 tools/components/prepare.py tools
|
||||
|
||||
- name: Build changed ARM64 preloads
|
||||
if: steps.component-preloads.outputs.arm64 != 'true'
|
||||
run: python3 tools/components/prepare.py arm64-preloads
|
||||
|
||||
- name: Prepare remaining Linux runtime assets
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: python3 tools/components/prepare.py runtime
|
||||
|
||||
- name: Fetch the Debian image the preloads build in
|
||||
if: steps.component-preloads.outputs.x86 != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/components/prepare.py debian-image
|
||||
|
||||
- name: Build the x86 preloads FEX guests load (glibc 2.31)
|
||||
if: steps.component-preloads.outputs.x86 != 'true'
|
||||
run: python3 tools/components/prepare.py x86-preloads
|
||||
|
||||
- name: Bundle the droiddeck-esync packs
|
||||
run: python3 tools/components/prepare.py esync
|
||||
|
||||
- name: Fetch PulseAudio 13.0 source
|
||||
if: steps.pa-src.outputs.cache-hit != 'true'
|
||||
run: python3 tools/components/prepare.py pulseaudio-source
|
||||
|
||||
- name: Fetch DirectAudio (tools/directaudio/release.env)
|
||||
run: python3 tools/components/prepare.py directaudio
|
||||
|
||||
- name: Build the AAudio sink into the audio bundle
|
||||
run: python3 tools/components/prepare.py audio
|
||||
|
||||
- name: Build proot
|
||||
if: steps.proot.outputs.cache-hit != 'true'
|
||||
run: python3 tools/components/prepare.py proot
|
||||
|
||||
- name: Check the preloads
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
|
||||
- name: Build GOG Galaxy companion
|
||||
if: steps.comet.outputs.cache-hit != 'true'
|
||||
run: python3 tools/components/prepare.py comet
|
||||
|
||||
- name: Prepare graphics driver variants
|
||||
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
- name: Record and pack prepared components
|
||||
run: python3 tools/components/prepare.py pack
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: prepared-components
|
||||
path: app/build/prepared-components.tar.gz
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 14
|
||||
|
||||
build:
|
||||
needs: xbox
|
||||
name: Compile and test APK
|
||||
needs: [source, components, xbox]
|
||||
name: Compile APK and Android tests
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
@@ -150,7 +335,7 @@ jobs:
|
||||
- name: Merge onto main, or say what conflicts
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
|
||||
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
git config user.name "ci"
|
||||
@@ -208,7 +393,7 @@ jobs:
|
||||
|
||||
- name: Verify backend and APK source match
|
||||
env:
|
||||
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
|
||||
XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
||||
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
@@ -218,13 +403,11 @@ jobs:
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
cache-cleanup: on-success
|
||||
|
||||
- name: Resolve the runner's NDK
|
||||
run: |
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
|
||||
echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
- name: Resolve the pinned Android NDK
|
||||
run: bash tools/ci/resolve-ndk.sh
|
||||
|
||||
- name: Set the APK build label
|
||||
id: build-label
|
||||
@@ -250,264 +433,6 @@ jobs:
|
||||
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
|
||||
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cache the preloads
|
||||
id: preloads
|
||||
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', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', '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/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
|
||||
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
|
||||
D=app/src/main/assets/linuxfs
|
||||
mkdir -p "$D"
|
||||
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
|
||||
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
|
||||
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
|
||||
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"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
|
||||
mkdir -p "$D/usr/local/bin"
|
||||
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
|
||||
VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
|
||||
tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
|
||||
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
|
||||
done
|
||||
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
|
||||
[ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
|
||||
done
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
rm gamescope.tzst
|
||||
fi
|
||||
. tools/linuxfs/uruntime.env
|
||||
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
|
||||
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
|
||||
install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
|
||||
install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
|
||||
rm uruntime
|
||||
M="$D/usr/local/lib/mangoapp"
|
||||
mkdir -p "$M" mango-pkgs
|
||||
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
|
||||
echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
|
||||
rm mango-pkgs/pkg.tar.zst
|
||||
done
|
||||
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
|
||||
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
|
||||
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
|
||||
done
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
|
||||
-o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
mkdir -p pw-pkgs
|
||||
grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
|
||||
echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
|
||||
rm pw-pkgs/pkg.tar.zst
|
||||
done
|
||||
tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
|
||||
rm -rf pw-pkgs
|
||||
ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
|
||||
T="$D/usr/local/lib/droiddeck-msitools"
|
||||
mkdir -p "$T" msi-pkgs
|
||||
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
|
||||
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
|
||||
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
|
||||
rm msi-pkgs/pkg.tar.xz
|
||||
done
|
||||
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
||||
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
ls -l "$T"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
|
||||
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
|
||||
ls -lR "$D"
|
||||
|
||||
- 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 public.ecr.aws/docker/library/debian:bullseye bash -c '
|
||||
set -euo pipefail
|
||||
install_compilers() {
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -qq &&
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
|
||||
}
|
||||
printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
||||
if ! install_compilers; then
|
||||
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
||||
install_compilers
|
||||
fi
|
||||
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
|
||||
'
|
||||
sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
|
||||
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
|
||||
|
||||
- name: Bundle the droiddeck-esync packs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f tools/droiddeck-esync/release.env ]; then
|
||||
. tools/droiddeck-esync/release.env
|
||||
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
|
||||
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
|
||||
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
|
||||
mkdir -p app/src/main/assets/droiddeck-esync
|
||||
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
|
||||
rm sync-bundle.tzst
|
||||
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
|
||||
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
|
||||
for id in $revoked; do
|
||||
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
|
||||
echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
|
||||
rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
|
||||
fi
|
||||
done
|
||||
rm sync-index.json
|
||||
test -f app/src/main/assets/droiddeck-esync/index.json
|
||||
test -f app/src/main/assets/droiddeck-esync/index.json.sig
|
||||
ls -lR app/src/main/assets/droiddeck-esync
|
||||
fi
|
||||
|
||||
- name: Cache PulseAudio 13.0 source
|
||||
id: pa-src
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: pa13
|
||||
key: pulseaudio-src-13.0
|
||||
- name: Fetch PulseAudio 13.0 source
|
||||
if: steps.pa-src.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
|
||||
- name: Fetch DirectAudio (tools/directaudio/release.env)
|
||||
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
|
||||
- name: Build the AAudio sink into the audio bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
|
||||
rm -rf pa-bundle && mkdir -p pa-bundle
|
||||
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
|
||||
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
|
||||
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
|
||||
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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }}
|
||||
- name: Build proot
|
||||
if: steps.proot.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
- name: Check the preloads
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
|
||||
- name: Check Quest tracking IPC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
|
||||
mkdir -p build/vr/include
|
||||
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
|
||||
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
|
||||
/tmp/test-xr-tracking
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
|
||||
/tmp/test-xr-touch
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
|
||||
/tmp/test-pad-controller-config
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
|
||||
/tmp/test-xr-vsync
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
|
||||
/tmp/test-xr-frames
|
||||
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
|
||||
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
|
||||
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
|
||||
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
|
||||
-ldl -o /tmp/test-presenter-shutdown
|
||||
timeout 10 /tmp/test-presenter-shutdown
|
||||
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
id: rust
|
||||
run: |
|
||||
@@ -525,9 +450,10 @@ jobs:
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-ndk
|
||||
app/src/main/rust/stores/target
|
||||
key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
|
||||
key: stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs', 'app/src/main/rust/stores/.cargo/**', 'app/build.gradle') }}
|
||||
restore-keys: |
|
||||
stores-${{ steps.rust.outputs.rustc }}-
|
||||
stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-
|
||||
stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-
|
||||
|
||||
- name: cargo-ndk
|
||||
run: |
|
||||
@@ -545,18 +471,26 @@ jobs:
|
||||
restore-keys: |
|
||||
cxx-${{ env.NDK_VERSION }}-
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: prepared-components
|
||||
path: app/build
|
||||
- name: Verify and unpack prepared components
|
||||
env:
|
||||
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
||||
run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: xbox-arm64
|
||||
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
|
||||
|
||||
- name: Build GOG Galaxy companion
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rustup toolchain install 1.90.0 --profile minimal
|
||||
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
|
||||
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
|
||||
|
||||
- name: Cache checksum-pinned pack inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/droiddeck-pack-inputs
|
||||
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
|
||||
restore-keys: pack-inputs-v1-
|
||||
- name: Build the add-on packs
|
||||
run: bash tools/packs/build.sh app/build/packs
|
||||
|
||||
@@ -567,7 +501,6 @@ jobs:
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
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
|
||||
@@ -598,16 +531,11 @@ jobs:
|
||||
path: app/src/main/rust/stores/Cargo.lock
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Install Python test dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq python3-yaml
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
- name: Unit tests
|
||||
id: unit-tests
|
||||
env:
|
||||
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p app/build
|
||||
@@ -683,86 +611,84 @@ jobs:
|
||||
TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }}
|
||||
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
|
||||
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
run: bash tools/release/report-build.sh
|
||||
|
||||
python-tests:
|
||||
name: Python and native regression tests
|
||||
needs: source
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Merge the pinned APK base
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
MARK="<!-- droiddeck-pr-build -->"
|
||||
{
|
||||
echo "$MARK"
|
||||
echo "## Build of this pull request"
|
||||
echo
|
||||
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
|
||||
echo
|
||||
case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
|
||||
success:success:success)
|
||||
echo "### Build passed"
|
||||
echo
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
|
||||
echo
|
||||
if [ "$SAME_REPO" = true ]; then
|
||||
echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
|
||||
else
|
||||
echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
|
||||
fi
|
||||
echo
|
||||
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
|
||||
;;
|
||||
success:success:*)
|
||||
echo "### APK publication incomplete"
|
||||
echo
|
||||
echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
|
||||
;;
|
||||
success:failure:*)
|
||||
echo "### ERROR: Unit tests failed"
|
||||
echo
|
||||
echo "The APK compiled, but unit tests failed. No installable artifact was published."
|
||||
echo
|
||||
if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
|
||||
echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
|
||||
else
|
||||
echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
|
||||
fi
|
||||
;;
|
||||
success:*)
|
||||
echo "### Build incomplete"
|
||||
echo
|
||||
echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
|
||||
;;
|
||||
failure:*)
|
||||
echo "### ERROR: Build failed"
|
||||
echo
|
||||
echo "The compiler's errors ([full log]($RUN_URL)):"
|
||||
echo
|
||||
echo '```'
|
||||
{ grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
|
||||
| sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
|
||||
| grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
|
||||
echo '```'
|
||||
echo
|
||||
echo "<details><summary>What went wrong (Gradle)</summary>"
|
||||
echo
|
||||
echo '```'
|
||||
awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
;;
|
||||
skipped:*|cancelled:*)
|
||||
echo "### Not built"
|
||||
echo
|
||||
echo "The build did not run (see above, or [the run]($RUN_URL))."
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
} > /tmp/pr-report.md
|
||||
cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true)
|
||||
if [ -n "$ID" ]; then
|
||||
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
|
||||
fi
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
git config user.email "ci@users.noreply.github.com"
|
||||
git merge --no-ff --no-edit FETCH_HEAD
|
||||
- name: Verify backend and test source match
|
||||
env:
|
||||
XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
||||
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
|
||||
- name: Install Python test dependencies
|
||||
timeout-minutes: 7
|
||||
run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user libvulkan-dev mesa-vulkan-drivers
|
||||
- name: Resolve the pinned Android NDK
|
||||
run: bash tools/ci/resolve-ndk.sh
|
||||
|
||||
- name: Check Quest tracking IPC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
|
||||
mkdir -p build/vr/include
|
||||
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
|
||||
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
|
||||
/tmp/test-xr-tracking
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
|
||||
/tmp/test-xr-touch
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
|
||||
/tmp/test-pad-controller-config
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
|
||||
/tmp/test-xr-vsync
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
|
||||
/tmp/test-xr-frames
|
||||
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
|
||||
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
|
||||
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
|
||||
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
|
||||
-ldl -o /tmp/test-presenter-shutdown
|
||||
timeout 10 /tmp/test-presenter-shutdown
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
checks:
|
||||
name: Compile and test APK
|
||||
needs: [build, python-tests]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require compilation and every test job
|
||||
env:
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
PYTHON_RESULT: ${{ needs.python-tests.result }}
|
||||
run: test "$BUILD_RESULT" = success && test "$PYTHON_RESULT" = success
|
||||
|
||||
sign:
|
||||
needs: build
|
||||
needs: [build, checks]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -774,7 +700,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
packs:
|
||||
needs: build
|
||||
needs: [build, checks]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -794,6 +720,10 @@ jobs:
|
||||
CI_REPO: Droid-Deck/DroidDeck-CI
|
||||
KEEP: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-signed-*
|
||||
@@ -811,72 +741,13 @@ jobs:
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
number=$(jq -r .number <<<"$pr")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
else
|
||||
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
|
||||
echo "**Pushed to main** without a PR - $subject" > notes-head.md
|
||||
fi
|
||||
cat notes-head.md
|
||||
run: bash tools/release/describe-build.sh
|
||||
|
||||
- name: Publish to DroidDeck-CI
|
||||
id: publish
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag=main-${GITHUB_SHA::7}
|
||||
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
||||
rm signed/SHA256SUMS-*.txt
|
||||
notes="$(cat notes-head.md)
|
||||
|
||||
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
|
||||
Commit: $GITHUB_SHA
|
||||
VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(cat signed/SHA256SUMS.txt)
|
||||
\`\`\`"
|
||||
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
|
||||
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
|
||||
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
|
||||
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
|
||||
while IFS= read -r old; do
|
||||
if [ "$old" = "$tag" ]; then
|
||||
continue
|
||||
fi
|
||||
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
|
||||
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
|
||||
echo "::error::Could not delete old release $old"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
|
||||
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
|
||||
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
|
||||
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
|
||||
echo "release=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
||||
echo "Published $url"
|
||||
run: bash tools/release/publish-ci.sh
|
||||
|
||||
- name: Post the Preview build to Discord
|
||||
env:
|
||||
@@ -884,26 +755,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
|
||||
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
|
||||
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
|
||||
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
|
||||
if [ -n "$pr" ]; then
|
||||
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
|
||||
else
|
||||
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
|
||||
fi
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
run: bash tools/release/announce-preview.sh
|
||||
|
||||
|
||||
catalog:
|
||||
needs: announce
|
||||
|
||||
@@ -9,6 +9,20 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
<p align="center"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/droid-deck/droiddeck"><img src="https://api.star-history.com/badge?repo=Droid-Deck/DroidDeck&type=rank&theme=dark" alt="Star History Rank" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/daily?language=C" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/daily?language=Kotlin" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/weekly?language=C" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/weekly?language=Kotlin" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
|
||||
|
||||
> Note: DroidDeck does not have a stand-alone website. Do not click on any download links from websites claiming to be the DroidDeck team.
|
||||
@@ -47,7 +61,7 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc/<pid>/status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. droiddeck-ntsync is checked against the object, wait and error semantics documented for the [Linux](https://github.com/torvalds/linux) ntsync driverLine truncated
|
||||
|
||||
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
|
||||
|
||||
@@ -56,3 +70,7 @@ Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](
|
||||
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
|
||||
|
||||
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment. Chromium and Electron apps from Flathub start their browser processes through [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause); its source was read to see that it always spawns them through Flatpak's spawn portal, which is why DroidDeck starts them directly without Chromium's sandbox instead.
|
||||
|
||||
Steam's Browse buttons open a GTK 3 file chooser through PyGObject (LGPL-2.1-or-later). It runs under Steam's own `reaper` with Steam's app ID, which [gamescope](https://github.com/ValveSoftware/gamescope) (BSD-2-Clause) reads from the process tree (`GetAppIdFromReaper` in `src/Utils/Process.cpp`) to show it over Steam.
|
||||
|
||||
Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose fixes for them (`311210.py` sets ALSA audio, `570940.py` installs VC++ 2017) fail under ARM64 Proton.
|
||||
+10
-3
@@ -80,7 +80,7 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO
|
||||
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
|
||||
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()] }
|
||||
@@ -122,8 +122,8 @@ android {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 14
|
||||
versionName "0.5.0"
|
||||
versionCode 15
|
||||
versionName "0.6.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -220,6 +220,10 @@ android {
|
||||
unitTests.returnDefaultValues = true
|
||||
unitTests.includeAndroidResources = true
|
||||
unitTests.all {
|
||||
if (ciBuild) {
|
||||
outputs.upToDateWhen { false }
|
||||
outputs.doNotCacheIf('CI executes the test suite on every run') { true }
|
||||
}
|
||||
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
|
||||
maxHeapSize = '2g'
|
||||
def logTarget = providers.systemProperty('robolectric.logging').orNull
|
||||
@@ -258,6 +262,7 @@ 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(rootProject.file('tools/linuxfs/innoextract'))
|
||||
from(layout.buildDirectory.dir('prepared-assets'))
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
@@ -333,6 +338,8 @@ dependencies {
|
||||
implementation 'androidx.camera:camera-camera2:1.4.2'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.4.2'
|
||||
implementation 'androidx.webkit:webkit:1.12.1'
|
||||
// Google's WebRTC (BSD) for the native Xbox Cloud Gaming player (XboxNativePlayerActivity).
|
||||
implementation 'io.github.webrtc-sdk:android:125.6422.07'
|
||||
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
|
||||
exclude group: 'com.google.auto.value', module: 'auto-value'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.content.pm.action.CONFIRM_INSTALL" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.content.pm.action.CONFIRM_PERMISSIONS" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
@@ -34,6 +43,7 @@
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:allowBackup="false"
|
||||
android:allowAudioPlaybackCapture="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
@@ -61,7 +71,7 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".FrontEndActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
|
||||
@@ -84,8 +94,8 @@
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".LauncherDark"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:name=".MainActivity"
|
||||
android:targetActivity=".FrontEndActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -97,7 +107,7 @@
|
||||
|
||||
<activity-alias
|
||||
android:name=".LauncherLight"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:targetActivity=".FrontEndActivity"
|
||||
android:icon="@mipmap/ic_launcher_light"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
@@ -151,6 +161,27 @@
|
||||
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity android:name=".stores.xbox.XboxCloudPlayerActivity" android:exported="false"
|
||||
android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<service android:name=".stores.xbox.XboxCloudTokenService" android:exported="false" />
|
||||
|
||||
<activity android:name=".stores.xbox.XboxNativePlayerActivity" android:exported="false"
|
||||
android:process=":xcloudnative" android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity android:name=".stores.xbox.XboxCloudActivity" android:exported="false"
|
||||
android:process=":xboxcloud" android:launchMode="singleTop" android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity android:name=".stores.xbox.XboxCloudSettingsActivity" android:exported="false"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
|
||||
#player { position: absolute; inset: 0; }
|
||||
#status { position: absolute; left: 0; right: 0; bottom: 12%; text-align: center; color: #ddd;
|
||||
font: 16px sans-serif; text-shadow: 0 0 4px #000; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="player"></div>
|
||||
<div id="status"></div>
|
||||
<script src="player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
/* DroidDeck Xbox Cloud Gaming player. Includes Greenlight's player (c) UnknownSKL (Jim Kroon), MIT. */
|
||||
(()=>{var _=class{_player;_dataChannel;constructor(e){this._player=e,this._dataChannel=this._player._peerConnection.createDataChannel(this.getChannelName(),this.getChannelConfig()),this._dataChannel.onopen=this.onOpen.bind(this),this._dataChannel.onmessage=this.onMessage.bind(this),this._dataChannel.onclosing=this.onClosing.bind(this),this._dataChannel.onclose=this.onClose.bind(this),this._dataChannel.onerror=this.onError.bind(this)}getChannelName(){return"channel"}getChannelConfig(){return{}}onOpen(e){console.log(this.getChannelName(),"Opening channel:",e)}onMessage(e){console.log(this.getChannelName(),"Message channel:",e)}onClosing(e){console.log(this.getChannelName(),"Closing channel:",e)}onClose(e){console.log(this.getChannelName(),"Closed channel:",e)}onError(e){console.log(this.getChannelName(),"Error channel:",e)}send(e){if(this._dataChannel.readyState!=="open"){console.log("["+this.getChannelName()+"] Tried to send data while channel is not open. readyState is:",this._dataChannel.readyState);return}return typeof e=="string"&&(e=new TextEncoder().encode(e)),this._dataChannel.send(e)}getPlayer(){return this._player}destroy(){}};var S=class extends _{_micPermissions="prompt";_micStream;constructor(e){super(e),navigator.permissions.query({name:"microphone"}).then(t=>{this._micPermissions=t.state,t.onchange=()=>{this._micPermissions=t.state}})}getChannelName(){return"chat"}getChannelConfig(){return{ordered:!0,protocol:"chatV1"}}destroy(){}};var v=class r{_player;_index;_physicalGamepadId=-1;_rumbleInterval;_isFocused=!0;_gamepadOverride={buttons:{}};_options={enable_keyboard:!1,enable_gamepad:!0,enable_vibration:!0,keyboard_mapping:{A:"Enter",B:"Backspace",X:"x",Y:"y",DPadUp:"ArrowUp",DPadDown:"ArrowDown",DPadLeft:"ArrowLeft",DPadRight:"ArrowRight",LeftShoulder:"[",RightShoulder:"]",LeftThumb:"l",RightThumb:"r",LeftTrigger:"-",RightTrigger:"=",Menu:"m",View:"v",Nexus:"n"},gamepad_mapping:{A:"0",B:"1",X:"2",Y:"3",DPadUp:"12",DPadDown:"13",DPadLeft:"14",DPadRight:"15",LeftShoulder:"4",RightShoulder:"5",LeftThumb:"10",RightThumb:"11",LeftTrigger:"6",RightTrigger:"7",Menu:"9",View:"8",Nexus:"16"},gamepad_axes_mapping:{LeftThumbXAxis:"0",LeftThumbYAxis:"1",RightThumbXAxis:"2",RightThumbYAxis:"3"},gamepad_deadzone:.08,gamepad_force_capture:!1};_listener={keyDown:this.onKeyDown.bind(this),keyUp:this.onKeyUp.bind(this),gamepadConnected:this.onGamepadConnected.bind(this),gamepadDisconnected:this.onGamepadDisconnected.bind(this),windowFocus:this.onWindowFocus.bind(this),windowBlur:this.onWindowBlur.bind(this)};constructor(e,t={}){this._index=e,this._options={...this._options,...t}}attach(e){this._player=e,this._player._channels.control.sendGamepadState(this._index,!0,this),this._options.enable_keyboard===!0&&(window.addEventListener("keydown",this._listener.keyDown),window.addEventListener("keyup",this._listener.keyUp)),this._options.enable_gamepad===!0&&(window.addEventListener("gamepadconnected",this._listener.gamepadConnected),window.addEventListener("gamepaddisconnected",this._listener.gamepadDisconnected)),this._options.enable_gamepad===!0&&this._options.gamepad_force_capture===!1&&(window.addEventListener("blur",this._listener.windowBlur),window.addEventListener("focus",this._listener.windowFocus)),this.detectActiveGamepad()}detach(){if(this._player===void 0){console.log("[Gamepad] Player is not attached. this._player is:",this._player);return}this._player._channels.control.sendGamepadState(this._index,!1),this._physicalGamepadId=-1,this._rumbleInterval!==void 0&&clearInterval(this._rumbleInterval),this._options.enable_keyboard===!0&&(window.removeEventListener("keydown",this._listener.keyDown),window.removeEventListener("keyup",this._listener.keyUp)),this._options.enable_gamepad===!0&&(window.removeEventListener("gamepadconnected",this._listener.gamepadConnected),window.removeEventListener("gamepaddisconnected",this._listener.gamepadDisconnected)),this._options.enable_gamepad===!0&&this._options.gamepad_force_capture===!1&&(window.removeEventListener("blur",this._listener.windowBlur),window.removeEventListener("focus",this._listener.windowFocus))}sendButtonState(e,t){if(this._player===void 0){console.log("[Gamepad] Player is not attached. this._player is:",this._player);return}if(!(e in this.getDefaultFamepadFrame())){console.log("[Gamepad] Invalid button:",e);return}let n=this.getDefaultFamepadFrame();n.GamepadIndex=this._index,this._gamepadOverride.buttons[e]=t}onKeyDown(e){if(this._isFocused!==!1)for(let t in this._options.keyboard_mapping)this._options.keyboard_mapping[t]===e.key&&(this._gamepadOverride.buttons[t]=1,e.preventDefault())}onKeyUp(e){if(this._isFocused!==!1)for(let t in this._options.keyboard_mapping)this._options.keyboard_mapping[t]===e.key&&(this._gamepadOverride.buttons[t]=0,e.preventDefault())}onWindowFocus(){this._isFocused=!0}onWindowBlur(){}getDefaultFamepadFrame(){return{GamepadIndex:0,Nexus:0,Menu:0,View:0,A:0,B:0,X:0,Y:0,DPadUp:0,DPadDown:0,DPadLeft:0,DPadRight:0,LeftShoulder:0,Line truncated
|
||||
|
||||
`+t.ContentText)?this.completeTransaction(e.id,{Result:0}):this.completeTransaction(e.id,{Result:1})}};var M=class extends _{_handshakeCompleted=!1;getChannelName(){return"message"}getChannelConfig(){return{protocol:"messageV1",ordered:!0}}onOpen(){console.log("[MessageChannel] Sending handshake...");let e=JSON.stringify({type:"Handshake",version:"messageV1",id:"be0bfc6d-1e83-4c8a-90ed-fa8601c5a179",cv:"0"});this.send(e)}onMessage(e){let t=JSON.parse(e.data);switch(t.type){case"HandshakeAck":console.log("[MessageChannel] Handshake completed"),this._handshakeCompleted=!0,this.getPlayer()._channels.control.sendAuthorization(),this.getPlayer()._channels.input.start(),this.sendConfig();break;case"TransactionStart":case"Message":new D(this,t);break;default:console.log("[MessageChannel] Unhandled message type:",t.type,t)}}sendConfig(){let e=[],t=[0,2,0],n=JSON.stringify(this.generateMessage("/streaming/systemUi/configuration",{version:t,systemUis:e}));this.send(n);let i=JSON.stringify(this.generateMessage("/streaming/properties/clientappinstallidchanged",{clientAppInstallId:"c97d7ee0-73b2-4239-bf1d-9d805a338429"}));this.send(i);let a=JSON.stringify(this.generateMessage("/streaming/characteristics/orientationchanged",{orientation:0}));this.send(a);let s=JSON.stringify(this.generateMessage("/streaming/characteristics/touchinputenabledchanged",{touchInputEnabled:!1}));this.send(s);let o=JSON.stringify(this.generateMessage("/streaming/characteristics/clientdevicecapabilities",{}));this.send(o);let d=JSON.stringify(this.generateMessage("/streaming/characteristics/dimensionschanged",{horizontal:1920,vertical:1080,preferredWidth:1920,preferredHeight:1080,safeAreaLeft:0,safeAreaTop:0,safeAreaRight:1920,safeAreaBottom:1080,supportsCustomResolution:!0}));this.send(d)}generateMessage(e,t){return{type:"Message",content:JSON.stringify(t),id:Q(),target:e,cv:""}}destroy(){}};var E=class{_address;_ipv4Address;_ipv4Port;constructor(e){this._address=e;let t=this._address.split(":"),n=t[6]+t[7],i=t[5],a=~parseInt(n.substring(0,2),16)&255,s=~parseInt(n.substring(2,4),16)&255,o=~parseInt(n.substring(4,6),16)&255,d=~parseInt(n.substring(6,8),16)&255,u=~parseInt(i.substring(0,4),16)&65535;this._ipv4Address=a+"."+s+"."+o+"."+d,this._ipv4Port=u}getIpv4Address(){return this._ipv4Address}getIpv4Port(){return this._ipv4Port}};var R=class{_peerConnection;_iceCandidates=[];constructor(e){this._peerConnection=e._peerConnection,this._peerConnection.addEventListener("icecandidate",t=>{t.candidate&&this._iceCandidates.push(t.candidate)})}getCandidates(){return this._iceCandidates}setRemoteCandidates(e){e.length===0&&console.warn("Error: No candidates received!");for(let t in e){if(e[t].candidate==="a=end-of-candidates")continue;let n=e[t].candidate.split(" ");if(n.length>4&&n[4].substr(0,4)==="2001"){let i=new E(n[4]);this._peerConnection.addIceCandidate({candidate:"a=candidate:10 1 UDP 1 "+i.getIpv4Address()+" 9002 typ host ",sdpMLineIndex:e[t].sdpMLineIndex,sdpMid:e[t].sdpMid}),this._peerConnection.addIceCandidate({candidate:"a=candidate:11 1 UDP 1 "+i.getIpv4Address()+" "+i.getIpv4Port()+" typ host ",sdpMLineIndex:e[t].sdpMLineIndex,sdpMid:e[t].sdpMid})}this._peerConnection.addIceCandidate({candidate:e[t].candidate,sdpMid:e[t].sdpMid,sdpMLineIndex:e[t].sdpMLineIndex})}}};var I=class{_player;_peerConnection;constructor(e){this._player=e,this._peerConnection=e._peerConnection}getAvailableCodecs(){let e=RTCRtpReceiver.getCapabilities("video")?.codecs;if(e===void 0)return[];let t=[];for(let n in e)t.includes(e[n].mimeType)||t.push(e[n].mimeType);return t}setLocalSDP(e){return console.log("[SDP] setLocalSDP() Setting local SDP:",e),e.sdp=e.sdp?.replace("useinbandfec=1","useinbandfec=1; stereo=1"),e}setRemoteSDP(e){return console.log("[SDP] setRemoteSDP() Setting remote SDP:",e),e}getDefaultCodecPreferences(){let e=RTCRtpReceiver.getCapabilities("video")?.codecs;if(e===void 0)return[];let t=[],n=[],i=[],a=[];for(let o in e)e[o].mimeType.includes("H264")?e[o].sdpFmtpLine?.includes("profile-level-id=4d")?t.push(e[o]):e[o].sdpFmtpLine?.includes("profile-level-id=42e")?n.push(e[o]):e[o].sdpFmtpLine?.includes("profile-level-id=420")&&i.push(e[o]):(e[o].mimeType.includes("ulpfec")||e[o].mimeType.includes("flexfec")||e[o].mimeType.includes("VP9")||e[o].mimeType.includes("VP8"))&&a.push(e[o]);return[...t,...n,...i,...a]}_setBitrate(e,t,n){let i=e.split(`
|
||||
`),a=-1;for(let o=0;o<i.length;o++)if(i[o].indexOf("m="+t)===0){a=o;break}if(a===-1)return console.debug("Could not find the m line for",t),e;for(a++;i[a].indexOf("i=")===0||i[a].indexOf("c=")===0;)a++;if(i[a].indexOf("b")===0)return i[a]="b=AS:"+n,i.join(`
|
||||
`);let s=i.slice(0,a);return s.push("b=AS:"+n),s=s.concat(i.slice(a,i.length)),s.join(`
|
||||
`)}};var P=class{_player;_peerConnection;_videoCodec="";_audioCodec="";_videoWidth=0;_videoHeight=0;_videoFps=0;_rtt=0;_remoteHost="";_remotePort=0;_remoteIsLocal=!1;_remoteIsIpv6=!1;_activeRemoteCandidate="";constructor(e){this._player=e,this._peerConnection=e._peerConnection,setInterval(this.loop.bind(this),1e3)}loop(){this._peerConnection.getStats().then(e=>{e.forEach(t=>{t.type==="inbound-rtp"?this.readInboundRtp(t):t.type==="candidate-pair"&&t.packetsReceived!==void 0&&t.packetsReceived>0?this.readCandidatePair(t):t.type==="remote-candidate"&&this.readRemoteCandidate(t)})})}readInboundRtp(e){e.kind==="video"&&e.codecId&&e.packetsReceived!==void 0&&e.packetsReceived>0?(e.codecId.includes("profile-level-id=4d")?this._videoCodec="H264 (High)":e.codecId.includes("profile-level-id=42e")?this._videoCodec="H264 (Normal)":e.codecId.includes("profile-level-id=420")?this._videoCodec="H264 (Low)":this._videoCodec="Other ("+e.codecId+")",e.frameWidth&&(this._videoWidth=e.frameWidth),e.frameHeight&&(this._videoHeight=e.frameHeight),e.framesPerSecond&&(this._videoFps=e.framesPerSecond)):e.kind==="audio"&&e.codecId&&e.packetsReceived!==void 0&&e.packetsReceived>0&&(this._audioCodec=e.codecId)}readCandidatePair(e){this._activeRemoteCandidate=e.remoteCandidateId,this._rtt=e.currentRoundTripTime}readRemoteCandidate(e){e.id===this._activeRemoteCandidate&&(this.isPrivateIP(e.address)?this._remoteIsLocal=!0:this._remoteIsLocal=!1,this.isIpv6(e.address)?this._remoteIsIpv6=!0:this._remoteIsIpv6=!1,this._remoteHost=e.address,this._remotePort=e.port)}isPrivateIP(e){let t=e.split(".");return t[0]==="10"||t[0]==="172"&&parseInt(t[1],10)>=16&&parseInt(t[1],10)<=31||t[0]==="192"&&t[1]==="168"}isIpv6(e){return e.split(":").length>3}};var G=class{_player;_videoComponent;_overlays={debug:void 0};constructor(e,t){this._videoComponent=e,this._player=t}toggleDebug(){this._overlays.debug===void 0?this.createDebugOverlay():this.destroy()}createDebugOverlay(){this._overlays.debug=document.createElement("div"),this._overlays.debug.id="playerDebugOverlay",this._overlays.debug.style.position="absolute",this._overlays.debug.style.top="0",this._overlays.debug.style.left="0",this._overlays.debug.style.width=this._videoComponent.getElement()?.style.width||"100%",this._overlays.debug.style.height=this._videoComponent.getElement()?.style.height||"auto",this._overlays.debug.style.background="linear-gradient(0deg, rgba(0,0,0, 0) 0%, rgba(0,0,0, 0) 75%, rgba(0,0,0, 0.5) 100%)",this._overlays.debug.style.padding="10px",this._overlays.debug.style.pointerEvents="none",document.getElementById(this._player.getElementId())?.appendChild(this._overlays.debug),setTimeout(()=>{this.refreshDebugOverlay()},500),this.refreshDebugOverlay()}destroy(){this._overlays.debug?.remove(),this._overlays.debug=void 0}refreshDebugOverlay(){if(this._overlays.debug!==void 0){this._overlays.debug.innerHTML="",this._overlays.debug.appendChild(this.createLabel("Local Play",this._player.getStats()._remoteIsLocal?"Local":"Remote","ok")),this._overlays.debug.appendChild(this.createLabel("Resolution",this._player.getStats()._videoWidth+"x"+this._player.getStats()._videoHeight,"ok")),this._overlays.debug.appendChild(this.createLabel("Stream FPS",this._player.getStats()._videoFps.toString(),this._player.getStats()._videoFps>=58?"ok":"warning"));let e=this._videoComponent.getFrameStats?.();e&&(this._overlays.debug.appendChild(this.createLabel("Render FPS",e.renderingFps.toString(),e.renderingFps>=58?"ok":"warning")),this._overlays.debug.appendChild(this.createLabel("Render Delay",e.renderDelayMs.toString()+" ms",e.renderDelayMs<16?"ok":e.renderDelayMs<33?"warning":"error")),this._overlays.debug.appendChild(this.createLabel("Dropped Frames",e.droppedFrames.toString(),e.droppedFrames===0?"ok":e.droppedFrames<30?"warning":"error"))),this._overlays.debug.appendChild(this.createLabel("Connection",this._player.getStats()._remoteIsIpv6?"IPv6":"IPv4","ok")),this._overlays.debug.appendChild(this.createLabel("Video",this._player.getStats()._videoCodec,"ok"));let t=this._player.getStats()._rtt*1e3>75?"error":this._player.getStats()._rtt*1e3>40?"warning":"ok";this._overlays.debug.appendChild(this.createLabel("RTT",(this._player.getStats()._rtt*1e3).toString()+" ms",t)),setTimeout(()=>{this.refreshDebugOverlay()},500)}}createLabel(e,t="",n=""){let i={borderRadius:"5px",background:"linear-gradient(0deg, rgba(28,26,26,1) 0%, rgba(47,45,45,1) 100%)",padding:"10px",fontSize:"12px",marginTop:"3px",marginRight:"10px",marginBottom:"2px",textTransform:"uppercase",color:"white",display:"inline-block"},a=document.createElement("span"),s={error:"rgb(220, 53, 69)",warning:"rgb(255, 193, 7)",ok:"rgb(25, 135, 84)"};t!==""?a.innerHTML=`${e}: <span style="background: ${s[n]}; border-radius: 5px; padding: 4px; padding-left: 8px; padding-right: 8px;">${t}</span>`:a.innerText=e;for(let o in i)a.style[o]=i[o];return a}};var L=class{_player;_element;_overlay;constructor(e){this._player=e,this._overlay=new G(this,this._pLine truncated
|
||||
in vec2 position;
|
||||
out vec2 vUV;
|
||||
void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`,j=`attribute vec2 position;
|
||||
varying vec2 vUV;
|
||||
void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`,Z=`#version 300 es
|
||||
precision mediump float;
|
||||
uniform sampler2D screenTexture;
|
||||
uniform vec2 resolution;
|
||||
uniform int filterId;
|
||||
uniform float sharpenFactor;
|
||||
uniform float brightness;
|
||||
uniform float contrast;
|
||||
uniform float gamma;
|
||||
uniform float saturation;
|
||||
uniform float deband;
|
||||
out vec4 fragColor;
|
||||
const float CAS_CONTRAST_PEAK = 0.8 * -3.0 + 8.0;
|
||||
|
||||
vec3 sharpen(vec2 coord, vec3 e) {
|
||||
vec2 px = 1.0 / resolution;
|
||||
vec3 b = texture(screenTexture, coord + px * vec2(0, 1)).rgb;
|
||||
vec3 d = texture(screenTexture, coord + px * vec2(-1, 0)).rgb;
|
||||
vec3 f = texture(screenTexture, coord + px * vec2(1, 0)).rgb;
|
||||
vec3 h = texture(screenTexture, coord + px * vec2(0, -1)).rgb;
|
||||
vec3 a = texture(screenTexture, coord + px * vec2(-1, 1)).rgb;
|
||||
vec3 c = texture(screenTexture, coord + px * vec2(1, 1)).rgb;
|
||||
vec3 g = texture(screenTexture, coord + px * vec2(-1, -1)).rgb;
|
||||
vec3 i = texture(screenTexture, coord + px * vec2(1, -1)).rgb;
|
||||
if (filterId == 1) {
|
||||
vec3 blur = ((a + c + g + i) + (b + d + f + h) * 2.0 + e * 4.0) / 16.0;
|
||||
return e + (e - blur) * sharpenFactor / 3.0;
|
||||
}
|
||||
vec3 minRgb = min(min(min(d, e), min(f, b)), h) + min(min(a, c), min(g, i));
|
||||
vec3 maxRgb = max(max(max(d, e), max(f, b)), h) + max(max(a, c), max(g, i));
|
||||
vec3 amplify = inversesqrt(clamp(min(minRgb, 2.0 - maxRgb) / maxRgb, 0.0, 1.0));
|
||||
vec3 weight = -(1.0 / (amplify * CAS_CONTRAST_PEAK));
|
||||
vec3 outColor = clamp(((b + d + f + h) * weight + e) / (4.0 * weight + 1.0), 0.0, 1.0);
|
||||
return mix(e, outColor, sharpenFactor / 2.0);
|
||||
}
|
||||
|
||||
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / resolution;
|
||||
vec3 color = texture(screenTexture, uv).rgb;
|
||||
if (sharpenFactor > 0.0) color = sharpen(uv, color);
|
||||
color = clamp(color + brightness, 0.0, 1.0);
|
||||
color = (color - 0.5) * clamp(contrast + 1.0, 0.5, 2.0) + 0.5;
|
||||
color = mix(vec3(dot(color, vec3(0.2126, 0.7152, 0.0722))), color, saturation);
|
||||
color = pow(max(color, 0.0), vec3(1.0 / gamma));
|
||||
if (deband > 0.0) color += vec3(((ign(gl_FragCoord.xy) + ign(gl_FragCoord.xy + vec2(11.0, 17.0))) - 1.0) * (deband / 255.0));
|
||||
fragColor = vec4(color, 1.0);
|
||||
}`,ee=`precision highp float;
|
||||
#define FXAA_MIN_REDUCE (1.0 / 128.0)
|
||||
#define FXAA_MUL_REDUCE (1.0 / 8.0)
|
||||
#define MAX_SPAN 8.0
|
||||
uniform sampler2D screenTexture;
|
||||
uniform vec2 resolution;
|
||||
const vec3 luma = vec3(0.299, 0.587, 0.114);
|
||||
void main() {
|
||||
vec2 invResolution = 1.0 / resolution;
|
||||
vec3 rgbNW = texture2D(screenTexture, (gl_FragCoord.xy + vec2(-1.0, -1.0)) * invResolution).rgb;
|
||||
vec3 rgbNE = texture2D(screenTexture, (gl_FragCoord.xy + vec2( 1.0, -1.0)) * invResolution).rgb;
|
||||
vec3 rgbSW = texture2D(screenTexture, (gl_FragCoord.xy + vec2(-1.0, 1.0)) * invResolution).rgb;
|
||||
vec3 rgbSE = texture2D(screenTexture, (gl_FragCoord.xy + vec2( 1.0, 1.0)) * invResolution).rgb;
|
||||
vec3 rgbM = texture2D(screenTexture, gl_FragCoord.xy * invResolution).rgb;
|
||||
float lumaNW = dot(rgbNW, luma);
|
||||
float lumaNE = dot(rgbNE, luma);
|
||||
float lumaSW = dot(rgbSW, luma);
|
||||
float lumaSE = dot(rgbSE, luma);
|
||||
float lumaM = dot(rgbM, luma);
|
||||
float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
|
||||
float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));
|
||||
vec2 dir;
|
||||
dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
|
||||
dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));
|
||||
float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * 0.25 * FXAA_MUL_REDUCE, FXAA_MIN_REDUCE);
|
||||
float minDirFactor = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);
|
||||
dir = clamp(dir * minDirFactor, vec2(-MAX_SPAN), vec2(MAX_SPAN)) * invResolution;
|
||||
vec4 rgbA = 0.5 * (
|
||||
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * (1.0 / 3.0 - 0.5)) +
|
||||
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * (2.0 / 3.0 - 0.5)));
|
||||
vec4 rgbB = rgbA * 0.5 + 0.25 * (
|
||||
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * -0.5) +
|
||||
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * 0.5));
|
||||
float lumaB = dot(rgbB, vec4(luma, 0.0));
|
||||
gl_FragColor = lumaB < lumaMin || lumaB > lumaMax ? rgbA : rgbB;
|
||||
}`,te=`precision highp float;
|
||||
uniform sampler2D screenTexture;
|
||||
uniform vec2 resolution;
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / resolution;
|
||||
float edgeThreshold = 0.2;
|
||||
vec2 offset = vec2(1.0) / resolution;
|
||||
vec3 colorCenter = texture2D(screenTexture, uv).rgb;
|
||||
vec3 colorLeft = texture2D(screenTexture, uv - vec2(offset.x, 0.0)).rgb;
|
||||
vec3 colorRight = texture2D(screenTexture, uv + vec2(offset.x, 0.0)).rgb;
|
||||
vec3 colorUp = texture2D(screenTexture, uv - vec2(0.0, offset.y)).rgb;
|
||||
vec3 colorDown = texture2D(screenTexture, uv + vec2(0.0, offset.y)).rgb;
|
||||
float diffHorizontal = length(colorRight - colorLeft);
|
||||
float diffVertical = length(colorUp - colorDown);
|
||||
float edgeFactor = step(edgeThreshold, diffHorizontal + diffVertical);
|
||||
vec3 outlineColor = mix(colorCenter, vec3(0.0), edgeFactor);
|
||||
gl_FragColor = vec4(outlineColor, 1.0);
|
||||
}`,ne=`precision highp float;
|
||||
#define CA_AMOUNT 1.0025
|
||||
#define SCANLINE_INTENSITY_X 0.125
|
||||
#define SCANLINE_INTENSITY_Y 0.375
|
||||
#define SCANLINE_SIZE 1024.0
|
||||
uniform sampler2D screenTexture;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vec4 finalColor = texture2D(screenTexture, vUV);
|
||||
finalColor.rgb = vec3(
|
||||
texture2D(screenTexture, (vUV - 0.5) * CA_AMOUNT + 0.5).r,
|
||||
finalColor.g,
|
||||
texture2D(screenTexture, (vUV - 0.5) / CA_AMOUNT + 0.5).b
|
||||
);
|
||||
float scanlineX = abs(sin(vUV.x * SCANLINE_SIZE) * 0.5 * SCANLINE_INTENSITY_X);
|
||||
float scanlineY = abs(sin(vUV.y * SCANLINE_SIZE) * 0.5 * SCANLINE_INTENSITY_Y);
|
||||
gl_FragColor = vec4(mix(finalColor.rgb, vec3(0.0), scanlineX + scanlineY), finalColor.a);
|
||||
}`,ie=`precision highp float;
|
||||
#define PI 3.14159265
|
||||
#define SCANLINE_INTENSITY 0.35
|
||||
#define CHROMA_OFFSET 0.005
|
||||
#define BLUR_RADIUS 0.002
|
||||
#define WARP_AMOUNT 0.01
|
||||
#define SCANLINE_DARKEN 0.5
|
||||
uniform sampler2D screenTexture;
|
||||
uniform int FrameCount;
|
||||
uniform vec2 TextureSize;
|
||||
uniform vec2 resolution;
|
||||
varying vec2 vUV;
|
||||
const mat3 yiq_mat = mat3(
|
||||
0.299, 0.587, 0.114,
|
||||
0.596, -0.275, -0.321,
|
||||
0.212, -0.523, 0.311
|
||||
);
|
||||
const mat3 yiq2rgb_mat = mat3(
|
||||
1.0, 0.956, 0.621,
|
||||
1.0, -0.272, -0.647,
|
||||
1.0, -1.106, 1.705
|
||||
);
|
||||
vec3 applyNTSC(vec2 uv) {
|
||||
vec3 col = texture2D(screenTexture, uv).rgb;
|
||||
vec3 yiq = col * yiq_mat;
|
||||
float chromaPhase = PI * (mod(uv.y * TextureSize.y, 2.0) + float(FrameCount));
|
||||
yiq.y *= cos(chromaPhase * 0.5);
|
||||
yiq.z *= sin(chromaPhase * 0.5);
|
||||
vec3 rgb = yiq * yiq2rgb_mat;
|
||||
vec3 finalColor;
|
||||
finalColor.r = texture2D(screenTexture, uv + vec2(CHROMA_OFFSET, 0.0)).r;
|
||||
finalColor.g = texture2D(screenTexture, uv + vec2(0.0, BLUR_RADIUS)).g;
|
||||
finalColor.b = texture2D(screenTexture, uv - vec2(CHROMA_OFFSET, 0.0)).b;
|
||||
return finalColor;
|
||||
}
|
||||
vec3 applyScanlines(vec2 uv) {
|
||||
vec3 col = texture2D(screenTexture, uv).rgb;
|
||||
float scanline = abs(sin(uv.y * resolution.y * 2.0)) * SCANLINE_INTENSITY;
|
||||
col *= 1.0 - (scanline * SCANLINE_DARKEN);
|
||||
return col;
|
||||
}
|
||||
vec2 applyWarp(vec2 uv) {
|
||||
uv = uv * 2.0 - 1.0;
|
||||
float r = sqrt(uv.x * uv.x + uv.y * uv.y);
|
||||
uv += uv * (r * r) * WARP_AMOUNT;
|
||||
return uv * 0.5 + 0.5;
|
||||
}
|
||||
void main() {
|
||||
vec2 warpedUV = applyWarp(vUV);
|
||||
vec3 ntscColor = applyNTSC(warpedUV);
|
||||
vec3 scanlineColor = applyScanlines(warpedUV);
|
||||
vec3 finalColor = mix(ntscColor, scanlineColor, 0.7);
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}`,H=class{constructor(e,t){this.video=e;this.effects=t;this.canvas=document.createElement("canvas"),Object.assign(this.canvas.style,{position:"absolute",inset:"0",width:"100%",height:"100%",objectFit:"contain",zIndex:"1"}),e.parentElement?.appendChild(this.canvas);let n=this.canvas.getContext("webgl2",{alpha:!1,antialias:!1,depth:!1,desynchronized:!0,powerPreference:"high-performance"});if(n===null)throw new Error("WebGL2 unavailable");this.gl=n;let i=n.createBuffer();n.bindBuffer(n.ARRAY_BUFFER,i),n.bufferData(n.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),n.STATIC_DRAW),this.passes.set("base",this.compile(J,Z,["screenTexture","resolution","filterId","sharpenFactor","brightness","contrast","gamma","saturation","deband"])),this.videoTexture=this.texture(),this.schedule()}canvas;gl;passes=new Map;videoTexture;targets=[];width=0;height=0;frame=0;lastDraw=0;stopped=!1;idle=!1;pending=!1;update(e){this.effects=e}pause(){this.idle||this.stopped||(this.idle=!0,this.canvas.style.display="none")}resume(e){this.effects=e,!(!this.idle||this.stopped)&&(this.idle=!1,this.canvas.style.display="",this.schedule())}schedule(){this.pending||(this.pending=!0,this.video.requestVideoFrameCallback(this.onFrame))}compile(e,t,n){let i=this.gl,a=(o,d)=>{let u=i.createShader(o);if(i.shaderSource(u,d),i.compileShader(u),!i.getShaderParameter(u,i.COMPILE_STATUS))throw new Error(`shader: ${i.getShaderInfoLog(u)}`);return u},s=i.createProgram();if(i.attachShader(s,a(i.VERTEX_SHADER,e)),i.attachShader(s,a(i.FRAGMENT_SHADER,t)),i.bindAttribLocation(s,0,"position"),i.linkProgram(s),!i.getProgramParameter(s,i.LINK_STATUS))throw new Error(`program: ${i.getProgramInfoLog(s)}`);return{program:s,uniforms:new Map(n.map(o=>[o,i.getUniformLocation(s,o)]))}}extra(e){let t=this.passes.get(e);if(t===void 0){let n={fxaa:ee,toon:te,crt:ne,ntsc:ie}[e];t=this.compile(j,n,["screenTexture","resolution","TextureSize","FrameCount"]),this.passes.set(e,t)}return t}texture(){let e=this.gl,t=e.createTexture();return e.bindTexture(e.TEXTURE_2D,t),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),t}resize(e,t){let n=this.gl;this.width=e,this.height=t,this.canvas.width=e,this.canvas.height=t;for(let i of this.targets)n.deleteFramebuffer(i.framebuffer),n.deleteTexture(i.texture);this.targets=[0,1].map(()=>{let i=this.texture();n.texImage2D(n.TEXTURE_2D,0,n.RGBA,e,t,0,n.RGBA,n.UNSIGNED_BYTE,null);let a=n.createFramebuffer();return n.bindFramebuffer(n.FRAMEBUFFER,a),n.framebufferTexture2D(n.FRAMEBUFFER,n.COLOR_ATTACHMENT0,n.TEXTURE_2D,i,0),{framebuffer:a,texture:i}}),n.bindFramebuffer(n.FRAMEBUFFER,null)}onFrame=e=>{if(this.pending=!1,this.stopped||this.idle)return;this.schedule();let t=this.effects.maxFps;if(!(t>0&&e-this.lastDraw<1e3/t-2)){this.lastDraw=e;try{this.draw()}catch(n){window.DDCloud?.log(`effects: ${String(n).slice(0,200)}`),this.destroy()}}};draw(){let e=this.gl,t=this.effects,n=this.video.videoWidth,i=this.video.videoHeight;if(n===0||i===0)return;(n!==this.width||i!==this.height)&&this.resize(n,i),this.frame++,e.activeTexture(e.TEXTURE0),e.bindTexture(e.TEXTURE_2D,this.videoTexture),e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,!0),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,this.video);let a=["fxaa","toon","crt","ntsc"].filter(d=>t[d]);e.viewport(0,0,n,i),e.enableVertexAttribArray(0),e.vertexAttribPointer(0,2,e.FLOAT,!1,0,0);let s=this.passes.get("base");e.bindFramebuffer(e.FRAMEBUFFER,a.length>0?this.targets[0].framebuffer:null),e.useProgram(s.program);let o=s.uniforms;e.uniform1i(o.get("screenTexture"),0),e.uniform2f(o.get("resolution"),n,i),e.uniform1i(o.get("filterId"),t.sharpenMode==="usm"?1:2),e.uniform1f(o.get("sharpenFactor"),t.sharpen),e.uniform1f(o.get("brightness"),t.brightness/100),e.uniform1f(o.get("contrast"),t.contrast/100),e.uniform1f(o.get("gamma"),Math.min(5,Math.max(.1,t.gamma))),e.uniform1f(o.get("saturation"),t.saturation/100),e.uniform1f(o.get("deband"),t.deband),e.drawArrays(e.TRIANGLES,0,3),a.forEach((d,u)=>{let h=this.extra(d),g=this.targets[u%2],p=u===a.length-1;e.bindFramebuffer(e.FRAMEBUFFER,p?null:this.targets[(u+1)%2].framebuffer),e.useProgram(h.program),e.bindTexture(e.TEXTURE_2D,g.texture);let c=h.uniforms;e.uniform1i(c.get("screenTexture")??null,0),e.uniform2f(c.get("resolution")??null,n,i),e.uniform2f(c.get("TextureSize")??null,n,i),e.uniform1i(c.get("FrameCount")??null,this.frame),e.drawArrays(e.TRIANGLES,0,3)})}destroy(){this.stopped=!0,this.canvas.remove(),this.gl.getExtension("WEBGL_lose_context")?.loseContext()}};var V=class{constructor(e){this.pc=e;this.element=document.createElement("div"),Object.assign(this.element.style,{position:"absolute",top:"8px",right:"8px",zIndex:"3",padding:"4px 8px",borderRadius:"6px",background:"rgba(0,0,0,0.55)",color:"#e8e8e8",font:"12px monospace",lineHeight:"16px",pointerEvent
Line truncated
|
||||
`:" \xB7 ")}destroy(){this.show(0,this.vertical,this.server,this.left),this.element.remove()}};function z(r,e){let t=e==="high"?"4d":e==="low"?"420":"42e",n=[];for(let d of r.matchAll(/a=fmtp:(\d+).*profile-level-id=([0-9a-f]{6})/g))d[2].startsWith(t)&&n.push(d[1]);if(n.length===0)return r;let i=r.split(`\r
|
||||
`),a=i.findIndex(d=>d.startsWith("m=video"));if(a<0)return r;let s=i[a].trim().split(" "),o=s.slice(3).filter(d=>!n.includes(d));return i[a]=s.slice(0,3).concat(n,o).join(" "),i.join(`\r
|
||||
`)}function K(r,e){let t=r.split(`\r
|
||||
`),n=t.findIndex(a=>a.startsWith("m=video"));if(n<0)return r;let i=`b=AS:${e}`;for(let a=n+1;a<=t.length;a++){let s=t[a];if(s===void 0||s.startsWith("m=")){t.splice(a,0,i);break}if(!(s.startsWith("i=")||s.startsWith("c="))){if(s.startsWith("b=AS:")){t[a]=i;break}t.splice(a,0,i);break}}return t.join(`\r
|
||||
`)}var m=JSON.parse(DDCloud.settings()),ae=RTCPeerConnection.prototype.setLocalDescription;RTCPeerConnection.prototype.setLocalDescription=function(r){return r?.sdp&&((m.codec==="high"||m.codec==="normal"||m.codec==="low")&&(r.sdp=z(r.sdp,m.codec)),m.bitrateMbps>0&&(r.sdp=K(r.sdp,m.bitrateMbps*1e3))),ae.call(this,r)};window.__ddRumble=m.rumble/100;window.addEventListener("error",r=>DDCloud.log(`error: ${r.message} @${r.lineno}`));window.addEventListener("unhandledrejection",r=>DDCloud.log(`rejection: ${String(r.reason).slice(0,200)}`));window.addEventListener("gamepadconnected",r=>DDCloud.log(`controller: ${r.gamepad.id.slice(0,60)} mapping=${r.gamepad.mapping||"none"} buttons=${r.gamepad.buttons.length} axes=${r.gamepad.axes.length}`));var W=new Map,re=1;window.__ddResolve=(r,e,t)=>{let n=W.get(r);W.delete(r),n&&n({status:e,body:t})};function C(r,e=""){return new Promise(t=>{let n=re++;W.set(n,t),DDCloud.call(n,r,typeof e=="string"?e:JSON.stringify(e))})}var B=r=>new Promise(e=>setTimeout(e,r)),se=r=>r.status>=200&&r.status<300;function x(r){let e=document.getElementById("status");e&&(e.textContent=r),DDCloud.status(r)}var T=class extends Error{constructor(t,n){super(t);this.stage=t;this.detail=n}};function k(r,e){if(!se(e))throw new T(r,`${e.status} ${e.body.slice(0,300)}`);return e}function oe(r,e){return new Promise(t=>{if(r.iceGatheringState==="complete")return t();let n=setTimeout(t,e);r.addEventListener("icegatheringstatechange",()=>{r.iceGatheringState==="complete"&&(clearTimeout(n),t())})})}async function de(){x("Starting the game on Xbox Cloud Gaming\u2026"),k("start",await C("start"));let r=!1,e=0;for(;;){let l=k("state",await C("state")),f=JSON.parse(l.body||"{}");if(f.state==="Provisioned")break;if(f.state==="Failed")throw new T("provision",JSON.stringify(f.errorDetails??{}).slice(0,300));f.state==="ReadyToConnect"&&!r?(r=!0,x("Signing in to the cloud console\u2026"),k("connect",await C("connect"))):f.state==="WaitingForResources"?x(`In the queue for a cloud console\u2026 ${Math.round(e/1e3)} s`):x(`Getting a cloud console ready\u2026 ${Math.round(e/1e3)} s`),await B(1e3),e+=1e3}x("Connecting\u2026");let t=new U("player");t.init();let n=t._peerConnection,i=t._channels.input,a=i.queueMetadataFrame.bind(i);i.queueMetadataFrame=l=>{if(m.preventDrops){let f=l.frameRenderedTimeMs-l.frameDecodedTimeMs;l.frameDecodedTimeMs=l.frameSubmittedTimeMs+10,l.frameRenderedTimeMs=l.frameDecodedTimeMs+f}a(l)};let s,o,d=()=>{if(!(o===void 0||s===void 0&&m.volume===100)){if(s===void 0){let l=new AudioContext;s=l.createGain(),l.createMediaStreamSource(o).connect(s).connect(l.destination);let f=document.querySelector("#player audio");f&&(f.muted=!0)}s.gain.value=m.volume/100}};n.addEventListener("track",l=>{l.track.kind==="audio"&&(o=l.streams[0],d())});let u=new V(n),h,g=()=>{let l=t.getVideoElement();if(l!==void 0){if(!X(m)){h?.pause();return}if(h===void 0)try{h=new H(l,m)}catch(f){DDCloud.log(`effects unavailable: ${String(f).slice(0,200)}`)}else h.resume(m)}};window.__ddApply=l=>{m=JSON.parse(l),window.__ddRumble=m.rumble/100,u.show(m.statsLevel,m.statsLayout==="vertical",m.server,m.statsCorner==="left"),d(),g()},n.addEventListener("connectionstatechange",()=>{n.connectionState==="connected"?(x(""),DDCloud.playing(),u.show(m.statsLevel,m.statsLayout==="vertical",m.server,m.statsCorner==="left"),setTimeout(g,500)):n.connectionState==="failed"?DDCloud.failed("webrtc","connection failed"):n.connectionState==="disconnected"&&x("Connection lost\u2026")});let p=await t.createOffer(),c=k("sdp",await C("sdp",p.sdp??"")),O=JSON.parse(JSON.parse(c.body).exchangeResponse);if(O.status&&O.status!=="success")throw new T("sdp-answer",JSON.stringify(O).slice(0,300));await t.setRemoteSDP(O.sdp),await oe(n,3e3);let Y=(await t.getICECandidates()).map(l=>JSON.stringify({candidate:l.candidate,sdpMid:l.sdpMid,sdpMLineIndex:l.sdpMLineIndex,usernameFragment:l.usernameFragment})),$=k("ice",await C("ice",Y));t.setRemoteIceCandidates(JSON.parse(JSON.parse($.body).exchangeResponse));let q=Date.now();for(;!t._channels.message._handshakeCompleted&&Date.now()-q<2e4;)await B(100);DDCloud.log(`handshake ${t._channels.message._handshakeCompleted?"done":"missing"} after ${Date.now()-q} ms`),await B(300);let b=new v(0,{enable_keyboard:!1});b.attach(t),DDCloud.log(`controllers at attach: ${Array.from(navigator.getGamepads()).filter(Boolean).length}`),window.__ddNexus=()=>{b.sendButtonState("Nexus",1),setTimeout(()=>b.sendButtonState("Nexus",0),200)},window.__ddButton=(l,f)=>b.sendButtonState(l,f),window.__ddInput=l=>{l?b.onWindowFocus():b.onWindowBlur()},setInterval(()=>{C("keepalive")},3e4),window.__ddStop=()=>{try{h?.destroy(),u.destroy(),b.detach(),t.destroy()}catch{}}}de().catch(r=>{r instanceof T?DDCloud.failed(r.stage,r.detail):DDCloud.failed("page",String(r).slice(0,300))});})();
|
||||
@@ -8,6 +8,7 @@ add_library(droiddeckwayland SHARED
|
||||
src/sc_layer.c
|
||||
src/ahb_swapchain.c
|
||||
src/waylandcomp_jni.c
|
||||
src/gpu_clock_pin.c
|
||||
src/droiddeck_ext.c
|
||||
src/wl_dmabuf.c
|
||||
src/wl_clipboard.c
|
||||
@@ -94,4 +95,5 @@ target_link_libraries(droiddeckwayland
|
||||
dl
|
||||
log
|
||||
android
|
||||
nativewindow
|
||||
)
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
#include <android/log.h>
|
||||
#include <android/hardware_buffer.h>
|
||||
#include <wayland-server.h>
|
||||
|
||||
#include "xdg-shell-server-protocol.h"
|
||||
@@ -263,6 +264,8 @@ static struct wl_event_source *g_fallback_timer;
|
||||
static int g_fallback_armed;
|
||||
|
||||
volatile int g_fps_limit;
|
||||
/* fps limit -1 (Performance mode without a limit): replaced buffers go back to the client at once instead of once per refresh. */
|
||||
volatile int g_release_unpaced;
|
||||
|
||||
volatile int g_hide_shell;
|
||||
|
||||
@@ -370,7 +373,7 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
|
||||
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
|
||||
const int limit = g_fps_limit;
|
||||
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
|
||||
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
if (g_release_unpaced || interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t now = now_ns();
|
||||
int64_t at = s->next_release_ns + interval;
|
||||
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
|
||||
@@ -808,10 +811,14 @@ static uint32_t nominal_refresh_ns(void) {
|
||||
}
|
||||
|
||||
static int64_t presented_ns(int64_t render_ns) {
|
||||
if (!g_tick_vsync_ns || render_ns - g_last_vsync_ns > (int64_t)nominal_refresh_ns() ||
|
||||
render_ns < g_tick_vsync_ns)
|
||||
int64_t tick = g_tick_vsync_ns;
|
||||
int64_t refresh = (int64_t)nominal_refresh_ns();
|
||||
if (!tick || render_ns - g_last_vsync_ns > refresh)
|
||||
return render_ns;
|
||||
return g_tick_vsync_ns;
|
||||
int64_t delta = render_ns - tick;
|
||||
if (delta > refresh || -delta > refresh)
|
||||
return render_ns;
|
||||
return tick;
|
||||
}
|
||||
|
||||
static void feedback_present_all(struct wl_list *list, int64_t t) {
|
||||
@@ -1841,7 +1848,121 @@ static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Stream mode (the Xbox Cloud Gaming native player, in its own process): no Wayland clients, the scene is one
|
||||
* picture handed over as AHardwareBuffers (ImageReader images of the decoded video). Each buffer is imported once
|
||||
* and kept while the reader cycles through its few buffers; the newest frame is the scene, so the screen effects
|
||||
* and frame generation apply to it as to a game.
|
||||
*/
|
||||
#define STREAM_SLOTS 6
|
||||
static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static atomic_int g_stream_on;
|
||||
static AHardwareBuffer *g_stream_pending;
|
||||
static struct { AHardwareBuffer *ahb; struct vkp_image *img; uint64_t used; } g_stream_slots[STREAM_SLOTS];
|
||||
static struct vkp_image *g_stream_img;
|
||||
static uint64_t g_stream_seq;
|
||||
static atomic_uint g_stream_frames;
|
||||
|
||||
static void stream_forget_all(void) {
|
||||
vkp_wait_idle();
|
||||
for (int i = 0; i < STREAM_SLOTS; i++) {
|
||||
if (g_stream_slots[i].img) vkp_image_destroy(g_stream_slots[i].img);
|
||||
if (g_stream_slots[i].ahb) AHardwareBuffer_release(g_stream_slots[i].ahb);
|
||||
g_stream_slots[i].img = NULL; g_stream_slots[i].ahb = NULL; g_stream_slots[i].used = 0;
|
||||
}
|
||||
g_stream_img = NULL;
|
||||
}
|
||||
|
||||
/* Compositor thread: the newest handed-over buffer becomes the scene picture. */
|
||||
static void stream_take(void) {
|
||||
pthread_mutex_lock(&g_stream_lock);
|
||||
AHardwareBuffer *ahb = g_stream_pending;
|
||||
g_stream_pending = NULL;
|
||||
pthread_mutex_unlock(&g_stream_lock);
|
||||
if (!ahb) return;
|
||||
if (!atomic_load(&g_stream_on)) { AHardwareBuffer_release(ahb); return; }
|
||||
int slot = -1, oldest = 0;
|
||||
for (int i = 0; i < STREAM_SLOTS; i++) {
|
||||
if (g_stream_slots[i].ahb == ahb) { slot = i; break; }
|
||||
if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i;
|
||||
}
|
||||
if (slot >= 0) {
|
||||
AHardwareBuffer_release(ahb); /* the slot keeps its own reference */
|
||||
} else {
|
||||
struct vkp_image *img = vkp_image_from_ahb(ahb);
|
||||
if (!img) {
|
||||
static int said;
|
||||
if (!said) { said = 1; droiddeck_log("stream", "a decoded frame could not be imported: nothing to show"); }
|
||||
AHardwareBuffer_release(ahb);
|
||||
return;
|
||||
}
|
||||
droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img));
|
||||
slot = oldest;
|
||||
if (g_stream_slots[slot].img) {
|
||||
vkp_wait_idle(); /* the GPU may still read the picture being replaced */
|
||||
if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL;
|
||||
vkp_image_destroy(g_stream_slots[slot].img);
|
||||
}
|
||||
if (g_stream_slots[slot].ahb) AHardwareBuffer_release(g_stream_slots[slot].ahb);
|
||||
g_stream_slots[slot].ahb = ahb;
|
||||
g_stream_slots[slot].img = img;
|
||||
}
|
||||
g_stream_slots[slot].used = ++g_stream_seq;
|
||||
g_stream_img = g_stream_slots[slot].img;
|
||||
atomic_fetch_add(&g_stream_frames, 1);
|
||||
schedule_render();
|
||||
}
|
||||
|
||||
static void render_stream(void) {
|
||||
g_dirty = 0;
|
||||
struct vkp_image *img = g_stream_img;
|
||||
if (!img) return;
|
||||
int w = vkp_image_width(img), h = vkp_image_height(img);
|
||||
if (w != g_scene_w || h != g_scene_h) {
|
||||
droiddeck_log("stream", "picture %dx%d", w, h);
|
||||
g_scene_w = w;
|
||||
g_scene_h = h;
|
||||
}
|
||||
struct vkp_draw d = {.img = img, .sx = 0, .sy = 0, .sw = (float)w, .sh = (float)h, .dx = 0, .dy = 0, .dw = w, .dh = h, .blend = 0};
|
||||
vkp_apply_window_request();
|
||||
sc_layer_hide();
|
||||
int r = vkp_render(w, h, &d, 1);
|
||||
if (r == 0) {
|
||||
g_stat_frames++;
|
||||
static int first;
|
||||
if (!first) { first = 1; droiddeck_log("stream", "first stream frame presented"); }
|
||||
} else {
|
||||
static int failed;
|
||||
if (failed++ % 600 == 0) droiddeck_log("stream", "presenting a stream frame failed (%d, %d times)", r, failed);
|
||||
}
|
||||
}
|
||||
|
||||
void droiddeck_stream_set(int on) {
|
||||
atomic_store(&g_stream_on, on ? 1 : 0);
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 21, on ? 1 : 0, 0, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Any thread: hands over one decoded frame (acquires its own reference). */
|
||||
void droiddeck_stream_frame(AHardwareBuffer *ahb) {
|
||||
if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return;
|
||||
AHardwareBuffer_acquire(ahb);
|
||||
pthread_mutex_lock(&g_stream_lock);
|
||||
AHardwareBuffer *dropped = g_stream_pending;
|
||||
g_stream_pending = ahb;
|
||||
pthread_mutex_unlock(&g_stream_lock);
|
||||
if (dropped) { AHardwareBuffer_release(dropped); return; } /* a wake-up is already queued */
|
||||
struct input_msg m = { 20, 0, 0, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
unsigned droiddeck_stream_frames(void) { return atomic_load(&g_stream_frames); }
|
||||
|
||||
static void render_scene(void) {
|
||||
if (atomic_load(&g_stream_on)) { render_stream(); return; }
|
||||
struct draw_list dl = {0};
|
||||
struct surface *s;
|
||||
int w, h;
|
||||
@@ -3001,6 +3122,8 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
break;
|
||||
case 10: break;
|
||||
case 11: axis_event(m.p1, m.p2); break;
|
||||
case 20: stream_take(); break;
|
||||
case 21: if (!m.p1) stream_forget_all(); schedule_render(); break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ static int ensure_ring(uint32_t w, uint32_t h, VkFormat fmt, uint32_t n) {
|
||||
}
|
||||
g_ring_w = w; g_ring_h = h; g_ring_fmt = fmt;
|
||||
LOGD("framegen: generation ring %ux%u x%u", w, h, n);
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -350,6 +350,7 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
|
||||
atomic_load(&g_model), atomic_load(&g_preset));
|
||||
}
|
||||
const int size_changed = g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt;
|
||||
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
|
||||
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
|
||||
refuse_format(fmt, kind, w, h, "build its chain");
|
||||
@@ -361,17 +362,25 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt) {
|
||||
if (size_changed) {
|
||||
g_built_w = (uint32_t)w; g_built_h = (uint32_t)h; g_built_fmt = fmt;
|
||||
g_generating_logged = 0;
|
||||
}
|
||||
uint32_t want = (uint32_t)(mult - 1);
|
||||
if (!ensure_ring((uint32_t)w, (uint32_t)h, fmt, want)) {
|
||||
const int ring = ensure_ring((uint32_t)w, (uint32_t)h, fmt, want);
|
||||
if (!ring) {
|
||||
if (fmt != VK_FORMAT_R8G8B8A8_UNORM) { refuse_format(fmt, kind, w, h, "make its generation ring"); return 0; }
|
||||
FGLOG("no memory for the generation ring (%dx%d x%u); frame generation stays off", w, h, want);
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
/* The ring and the engine's images are new on this frame. Dispatching into them now
|
||||
* null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel).
|
||||
* Show this frame as it is; generation starts once the size has settled. */
|
||||
if (size_changed || ring == 2) {
|
||||
FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
|
||||
* generations would otherwise read as a slower game and justify even more generations. */
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "gpu_clock_pin.h"
|
||||
|
||||
#include <android/log.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "../../adrenotools/src/hook/kgsl.h"
|
||||
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_t worker;
|
||||
static bool joinable;
|
||||
static atomic_bool running;
|
||||
|
||||
struct pin {
|
||||
int fd;
|
||||
unsigned int context;
|
||||
unsigned int memory;
|
||||
unsigned int timestamp;
|
||||
unsigned long gpuaddr;
|
||||
uint32_t nop;
|
||||
void *commands;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
static struct pin pin = { .fd = -1 };
|
||||
|
||||
static void release_pin(void) {
|
||||
if (pin.context) {
|
||||
struct kgsl_drawctxt_destroy destroy = { .drawctxt_id = pin.context };
|
||||
ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &destroy);
|
||||
}
|
||||
if (pin.commands) munmap(pin.commands, pin.size);
|
||||
if (pin.memory) {
|
||||
struct kgsl_gpumem_free_id free_memory = { .id = pin.memory };
|
||||
ioctl(pin.fd, IOCTL_KGSL_GPUMEM_FREE_ID, &free_memory);
|
||||
}
|
||||
if (pin.fd >= 0) close(pin.fd);
|
||||
pin = (struct pin) { .fd = -1 };
|
||||
}
|
||||
|
||||
static bool submit_pin(void) {
|
||||
struct kgsl_command_object object = {
|
||||
.gpuaddr = pin.gpuaddr,
|
||||
.size = 2 * sizeof(uint32_t),
|
||||
.flags = KGSL_CMDLIST_IB,
|
||||
.id = pin.memory,
|
||||
};
|
||||
struct kgsl_gpu_command command = {
|
||||
.flags = KGSL_CMDBATCH_PWR_CONSTRAINT,
|
||||
.cmdlist = (uintptr_t)&object,
|
||||
.cmdsize = sizeof(object),
|
||||
.numcmds = 1,
|
||||
.context_id = pin.context,
|
||||
};
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_GPU_COMMAND, &command) < 0) return false;
|
||||
pin.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool prepare_pin(void) {
|
||||
pin.fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
|
||||
if (pin.fd < 0) return false;
|
||||
struct kgsl_devinfo info = {0};
|
||||
struct kgsl_device_getproperty device_info = {
|
||||
.type = KGSL_PROP_DEVICE_INFO, .value = &info, .sizebytes = sizeof(info),
|
||||
};
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &device_info) < 0) return false;
|
||||
unsigned int core = info.chip_id >> 24;
|
||||
if (!core) core = info.gpu_id / 100;
|
||||
if (core < 3) { errno = ENODEV; return false; }
|
||||
pin.nop = core >= 5 ? 0x70100001 : 0xc0001000;
|
||||
struct kgsl_drawctxt_create context = {
|
||||
.flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE | KGSL_CONTEXT_PWR_CONSTRAINT,
|
||||
};
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_CREATE, &context) < 0) return false;
|
||||
pin.context = context.drawctxt_id;
|
||||
struct kgsl_device_constraint_pwrlevel level = { .level = KGSL_CONSTRAINT_PWR_MAX };
|
||||
struct kgsl_device_constraint constraint = {
|
||||
.type = KGSL_CONSTRAINT_PWRLEVEL,
|
||||
.context_id = pin.context,
|
||||
.data = &level,
|
||||
.size = sizeof(level),
|
||||
};
|
||||
struct kgsl_device_getproperty property = {
|
||||
.type = KGSL_PROP_PWR_CONSTRAINT,
|
||||
.value = &constraint,
|
||||
.sizebytes = sizeof(constraint),
|
||||
};
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_SETPROPERTY, &property) < 0) return false;
|
||||
struct kgsl_gpumem_alloc_id memory = { .size = 4096 };
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &memory) < 0) return false;
|
||||
pin.memory = memory.id;
|
||||
pin.gpuaddr = memory.gpuaddr;
|
||||
pin.size = memory.mmapsize;
|
||||
void *commands = mmap(NULL, pin.size, PROT_READ | PROT_WRITE, MAP_SHARED, pin.fd,
|
||||
(off_t)pin.memory << 12);
|
||||
if (commands == MAP_FAILED) return false;
|
||||
pin.commands = commands;
|
||||
// A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits.
|
||||
uint32_t *words = commands;
|
||||
words[0] = pin.nop;
|
||||
words[1] = 0;
|
||||
struct kgsl_gpumem_sync_cache sync = { .id = pin.memory, .op = KGSL_GPUMEM_CACHE_CLEAN };
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_SYNC_CACHE, &sync) < 0) return false;
|
||||
return submit_pin();
|
||||
}
|
||||
|
||||
static void *hold_pin(void *unused) {
|
||||
(void)unused;
|
||||
while (atomic_load(&running)) {
|
||||
struct kgsl_device_waittimestamp_ctxtid wait = {
|
||||
.context_id = pin.context,
|
||||
.timestamp = pin.timestamp,
|
||||
.timeout = 100,
|
||||
};
|
||||
if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait) < 0) {
|
||||
if (errno == EINTR || errno == ETIMEDOUT) continue;
|
||||
break;
|
||||
}
|
||||
// Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive.
|
||||
usleep(25000);
|
||||
if (atomic_load(&running) && !submit_pin()) break;
|
||||
}
|
||||
if (atomic_exchange(&running, false))
|
||||
__android_log_print(ANDROID_LOG_WARN, "GpuClockPin", "constraint stopped: %s", strerror(errno));
|
||||
release_pin();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool gpu_clock_pin_set(bool on) {
|
||||
pthread_mutex_lock(&lock);
|
||||
if (joinable) {
|
||||
if (on && atomic_load(&running)) {
|
||||
pthread_mutex_unlock(&lock);
|
||||
return true;
|
||||
}
|
||||
atomic_store(&running, false);
|
||||
pthread_join(worker, NULL);
|
||||
joinable = false;
|
||||
}
|
||||
bool ok = true;
|
||||
if (on) {
|
||||
ok = prepare_pin();
|
||||
if (ok) {
|
||||
atomic_store(&running, true);
|
||||
int error = pthread_create(&worker, NULL, hold_pin, NULL);
|
||||
if (error) {
|
||||
atomic_store(&running, false);
|
||||
errno = error;
|
||||
ok = false;
|
||||
} else {
|
||||
joinable = true;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
int error = errno;
|
||||
release_pin();
|
||||
errno = error;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
bool gpu_clock_pin_set(bool on);
|
||||
@@ -21,7 +21,7 @@
|
||||
X(BindImageMemory) X(DestroyImage) X(FreeMemory) X(AcquireNextImageKHR) \
|
||||
X(ResetCommandBuffer) X(BeginCommandBuffer) X(CmdPipelineBarrier) X(CmdBlitImage) \
|
||||
X(EndCommandBuffer) X(ResetFences) X(QueueSubmit) X(QueuePresentKHR) X(WaitForFences) \
|
||||
X(QueueWaitIdle) X(GetMemoryFdPropertiesKHR) \
|
||||
X(QueueWaitIdle) X(GetMemoryFdPropertiesKHR) X(GetAndroidHardwareBufferPropertiesANDROID) \
|
||||
X(GetImageSubresourceLayout) X(MapMemory) X(UnmapMemory) X(CmdClearColorImage) \
|
||||
\
|
||||
X(CreateShaderModule) X(DestroyShaderModule) X(CreatePipelineLayout) X(DestroyPipelineLayout) \
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
#include <android/log.h>
|
||||
#include <android/hardware_buffer.h>
|
||||
|
||||
#define TAG "DroidDeckWayland"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
@@ -39,6 +40,7 @@ struct vkp_image {
|
||||
};
|
||||
|
||||
static ANativeWindow *g_window;
|
||||
static int g_ahb_import;
|
||||
static char *g_driver_path, *g_library_name, *g_native_lib_dir;
|
||||
static int g_dev_state;
|
||||
static VkInstance g_inst;
|
||||
@@ -459,17 +461,19 @@ static int dev_init(void) {
|
||||
}
|
||||
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
|
||||
|
||||
const char *dev_exts[7] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME, "VK_KHR_external_memory_fd",
|
||||
"VK_EXT_external_memory_dma_buf", "VK_EXT_image_drm_format_modifier",
|
||||
"VK_KHR_image_format_list", NULL, NULL};
|
||||
uint32_t n_dev_exts = 5;
|
||||
static const char *const import_exts[4] = {"VK_KHR_external_memory_fd", "VK_EXT_external_memory_dma_buf",
|
||||
"VK_EXT_image_drm_format_modifier", "VK_KHR_image_format_list"};
|
||||
const char *dev_exts[9] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME};
|
||||
uint32_t n_dev_exts = 1;
|
||||
uint32_t ne = 0;
|
||||
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL);
|
||||
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts));
|
||||
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts);
|
||||
for (unsigned i = 0; i < 5; i++)
|
||||
if (!has_ext(exts, ne, dev_exts[i]))
|
||||
LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]);
|
||||
/* Only what the driver has: the system driver lacks the dma-buf ones, which the cloud player never needs. */
|
||||
for (unsigned i = 0; i < 4; i++) {
|
||||
if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i];
|
||||
else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]);
|
||||
}
|
||||
int want_hdr_md = 0;
|
||||
if (droiddeck_color_requested()) {
|
||||
want_hdr_md = has_ext(exts, ne, VK_EXT_HDR_METADATA_EXTENSION_NAME);
|
||||
@@ -477,6 +481,13 @@ static int dev_init(void) {
|
||||
}
|
||||
const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME);
|
||||
if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME;
|
||||
/* The cloud player's decoded frames arrive as AHardwareBuffers (vkp_image_from_ahb). */
|
||||
g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) &&
|
||||
has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME);
|
||||
if (g_ahb_import) {
|
||||
dev_exts[n_dev_exts++] = VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME;
|
||||
dev_exts[n_dev_exts++] = VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME;
|
||||
}
|
||||
free(exts);
|
||||
|
||||
float prio = 1.0f;
|
||||
@@ -987,6 +998,60 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
return img;
|
||||
}
|
||||
|
||||
void vkp_wait_idle(void) {
|
||||
if (g_dev && g_vk.DeviceWaitIdle) g_vk.DeviceWaitIdle(g_dev);
|
||||
}
|
||||
|
||||
struct vkp_image *vkp_image_from_ahb(AHardwareBuffer *ahb) {
|
||||
if (!ahb || dev_init() != 0) return NULL;
|
||||
if (!g_ahb_import || !g_vk.GetAndroidHardwareBufferPropertiesANDROID) {
|
||||
static int said;
|
||||
if (!said) { said = 1; LOGE("ahb: the driver cannot import AHardwareBuffers (VK_ANDROID_external_memory_android_hardware_buffer)"); }
|
||||
return NULL;
|
||||
}
|
||||
AHardwareBuffer_Desc desc;
|
||||
AHardwareBuffer_describe(ahb, &desc);
|
||||
VkAndroidHardwareBufferFormatPropertiesANDROID fp = {
|
||||
.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID};
|
||||
VkAndroidHardwareBufferPropertiesANDROID props = {
|
||||
.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID, .pNext = &fp};
|
||||
VkResult pr = g_vk.GetAndroidHardwareBufferPropertiesANDROID(g_dev, ahb, &props);
|
||||
if (pr != VK_SUCCESS || fp.format == VK_FORMAT_UNDEFINED) {
|
||||
LOGE("ahb: %ux%u format %u: properties %s, Vulkan format %d", desc.width, desc.height, desc.format,
|
||||
vk_result_name(pr), (int)fp.format);
|
||||
return NULL;
|
||||
}
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = (int)desc.width; img->h = (int)desc.height; img->dmabuf = 1; img->fmt = fp.format; img->sampled = 1;
|
||||
VkExternalMemoryImageCreateInfo ext = {
|
||||
.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO,
|
||||
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID};
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &ext,
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = fp.format, .extent = {desc.width, desc.height, 1},
|
||||
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
|
||||
if (cr != VK_SUCCESS) { LOGE("ahb: vkCreateImage %ux%u -> %s", desc.width, desc.height, vk_result_name(cr)); free(img); return NULL; }
|
||||
int idx = -1;
|
||||
for (int i = 0; i < 32; i++) if (props.memoryTypeBits & (1u << i)) { idx = i; break; }
|
||||
VkImportAndroidHardwareBufferInfoANDROID imp = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, .buffer = ahb};
|
||||
VkMemoryDedicatedAllocateInfo ded = {.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO,
|
||||
.pNext = &imp, .image = img->image};
|
||||
VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .pNext = &ded,
|
||||
.allocationSize = props.allocationSize, .memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
|
||||
VkResult mr = idx < 0 ? VK_ERROR_FEATURE_NOT_PRESENT : g_vk.AllocateMemory(g_dev, &mai, NULL, &img->mem);
|
||||
if (mr != VK_SUCCESS || g_vk.BindImageMemory(g_dev, img->image, img->mem, 0) != VK_SUCCESS) {
|
||||
LOGE("ahb: importing %ux%u -> %s", desc.width, desc.height, vk_result_name(mr));
|
||||
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
|
||||
g_vk.DestroyImage(g_dev, img->image, NULL); free(img); return NULL;
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
static int shm_sampled(void) {
|
||||
static int known;
|
||||
if (!known) {
|
||||
@@ -1626,6 +1691,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
if (fx) {
|
||||
int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f);
|
||||
/* Frame generation samples every output pixel. Upscaling first made it interpolate
|
||||
* the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame.
|
||||
* Grade at the game's size instead; the screen blit still scales to the panel. */
|
||||
if (fg) { mapped_w = scene_w; mapped_h = scene_h; }
|
||||
vkp_effects_set_formats(scene_fmt, scene_fmt);
|
||||
result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh);
|
||||
result_layout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
|
||||
|
||||
@@ -34,6 +34,9 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
int vkp_image_is_dmabuf(const struct vkp_image *img);
|
||||
|
||||
struct vkp_image *vkp_image_create_shm(int w, int h);
|
||||
struct AHardwareBuffer;
|
||||
struct vkp_image *vkp_image_from_ahb(struct AHardwareBuffer *ahb);
|
||||
void vkp_wait_idle(void);
|
||||
void vkp_image_upload_shm(struct vkp_image *img, const void *data, int stride);
|
||||
|
||||
int vkp_image_width(const struct vkp_image *img);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <sys/resource.h>
|
||||
#include <android/log.h>
|
||||
#include <android/native_window_jni.h>
|
||||
#include <android/hardware_buffer_jni.h>
|
||||
#include "vk_present.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "ahb_swapchain.h"
|
||||
@@ -16,6 +17,7 @@
|
||||
#include "effects_chain.h"
|
||||
#include "cardboard.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "gpu_clock_pin.h"
|
||||
|
||||
extern int droiddeck_wayland_run(void);
|
||||
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
|
||||
@@ -26,9 +28,13 @@ extern int droiddeck_cursor_serial(void);
|
||||
extern void droiddeck_wayland_send_key(int evdev, int state);
|
||||
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
|
||||
extern void droiddeck_stream_set(int on);
|
||||
extern void droiddeck_stream_frame(AHardwareBuffer *ahb);
|
||||
extern unsigned droiddeck_stream_frames(void);
|
||||
extern void droiddeck_wayland_output_resized(void);
|
||||
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
|
||||
extern volatile int g_fps_limit;
|
||||
extern volatile int g_release_unpaced;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
extern volatile unsigned g_zero_copy_last;
|
||||
@@ -569,6 +575,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLookName(JNIEnv *
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFpsLimit(JNIEnv *env, jclass clazz, jint fps) {
|
||||
g_fps_limit = fps > 0 ? fps : 0;
|
||||
g_release_unpaced = fps < 0;
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "fps limit %d", fps);
|
||||
}
|
||||
|
||||
@@ -634,8 +641,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIE
|
||||
extern void adrenotools_set_turbo(bool turbo);
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
(void)env; (void)clazz;
|
||||
adrenotools_set_turbo(on == JNI_TRUE);
|
||||
(void)clazz;
|
||||
if (!gpu_clock_pin_set(on == JNI_TRUE)) {
|
||||
int saved_errno = errno;
|
||||
jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException");
|
||||
if (error) (*env)->ThrowNew(env, error, strerror(saved_errno));
|
||||
}
|
||||
// Clear a legacy global pin when recovering its marker after an app upgrade or crash.
|
||||
if (on != JNI_TRUE) adrenotools_set_turbo(false);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
@@ -660,3 +673,21 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP
|
||||
(*env)->GetFloatArrayRegion(env, pos, 0, 3, p);
|
||||
vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0);
|
||||
}
|
||||
|
||||
/* The Xbox Cloud Gaming native player: its decoded frames are the whole scene (no Wayland clients). */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
droiddeck_stream_set(on ? 1 : 0);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamFrame(JNIEnv *env, jclass clazz, jobject hardwareBuffer) {
|
||||
if (!hardwareBuffer) return;
|
||||
AHardwareBuffer *ahb = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer);
|
||||
if (ahb) droiddeck_stream_frame(ahb);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamFrames(JNIEnv *env, jclass clazz) {
|
||||
return (jint)droiddeck_stream_frames();
|
||||
}
|
||||
@@ -25,9 +25,19 @@ class App : Application() {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
|
||||
return
|
||||
}
|
||||
if (process == "$packageName:xboxcloud") {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xboxcloud")
|
||||
return
|
||||
}
|
||||
if (process == "$packageName:xcloudnative") {
|
||||
// The native cloud player: the stores log and nothing that touches the Linux session.
|
||||
com.droiddeck.launcher.stores.StoresState.init(this)
|
||||
return
|
||||
}
|
||||
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
com.droiddeck.launcher.core.DownloadSource.init(this)
|
||||
@@ -43,6 +53,7 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
|
||||
}
|
||||
|
||||
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
|
||||
|
||||
@@ -55,6 +55,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
private set
|
||||
private var runtimePending by mutableStateOf(false)
|
||||
private var sessionRunning by mutableStateOf(false)
|
||||
var tpUbwcMode: String by mutableStateOf(SessionPrefs.TP_UBWC_AUTO)
|
||||
private set
|
||||
|
||||
val uiMode: String get() = when (mode) {
|
||||
SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO
|
||||
@@ -73,8 +75,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
|
||||
canRestart = !sessionRunning,
|
||||
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
|
||||
tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
|
||||
tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
|
||||
)
|
||||
|
||||
fun chooseTpUbwcMode(mode: String) {
|
||||
if (gpu.model != 740) return
|
||||
SessionPrefs.setTpUbwcMode(activity, mode)
|
||||
tpUbwcMode = mode
|
||||
}
|
||||
|
||||
private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when {
|
||||
uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto
|
||||
uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair
|
||||
@@ -124,6 +134,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
fun refreshDrivers() {
|
||||
tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
|
||||
+170
-31
@@ -80,7 +80,7 @@ import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class FrontEndActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
private val components = ComponentsMenu(this, ui)
|
||||
private val decky = DeckyMenu(this, ui)
|
||||
private val updates = UpdatesMenu(this, ui)
|
||||
private val protons = ProtonMenu(this, ui)
|
||||
private val protons = ProtonMenu(this, ui) { !busy && flood == null && pkgStage == null && components.compBusy == null }
|
||||
|
||||
private var installed by mutableStateOf<String?>(null)
|
||||
private var flood by mutableStateOf<PendingFlood?>(null)
|
||||
@@ -120,6 +120,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var returnAfterGame by mutableStateOf(true)
|
||||
private var shaderPrecache by mutableStateOf<Boolean?>(null)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
@@ -138,6 +139,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var pkgPercent by mutableIntStateOf(-1)
|
||||
private var desktopInstalled by mutableStateOf(false)
|
||||
private var desktopPresent by mutableStateOf(false)
|
||||
private var battleNetInstalled by mutableStateOf(false)
|
||||
private var offlineAccount by mutableStateOf<String?>(null)
|
||||
private var offline by mutableStateOf(false)
|
||||
private var showPerformance by mutableStateOf(false)
|
||||
@@ -151,9 +153,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var syncFallback by mutableStateOf(true)
|
||||
private var fsyncFirst by mutableStateOf(false)
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var ramCapMb by mutableStateOf(0)
|
||||
private var zramClient by mutableStateOf(true)
|
||||
private var vramCapMb by mutableStateOf(0)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var prootFastPath by mutableStateOf(true)
|
||||
private var cursorComposite by mutableStateOf(false)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(null)
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
@@ -161,16 +167,16 @@ class MainActivity : ComponentActivity() {
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var picturePosition by mutableStateOf("center")
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
|
||||
private var downloadMirror by mutableStateOf("")
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var downloadProxy by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
|
||||
@@ -386,14 +392,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var performanceMode by mutableStateOf(false)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var zramCompression by mutableStateOf(false)
|
||||
private var zramSupported by mutableStateOf<Boolean?>(null)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var gameStorage by mutableStateOf("")
|
||||
@@ -412,6 +417,10 @@ class MainActivity : ComponentActivity() {
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
private var vacDiagnostics by mutableStateOf(false)
|
||||
private var agentCommands by mutableStateOf(false)
|
||||
private var clearCachedApps by mutableStateOf(false)
|
||||
private var adbPaired by mutableStateOf(false)
|
||||
private var memoryHud by mutableStateOf(SessionPrefs.MEMORY_HUD_OFF)
|
||||
private var backgroundAppLimit by mutableStateOf(com.droiddeck.launcher.core.BackgroundAppLimit.OFF)
|
||||
private var showRoms by mutableStateOf(false)
|
||||
private var homeAppSelected by mutableStateOf(false)
|
||||
private var homeScreenEnabled by mutableStateOf(false)
|
||||
@@ -460,7 +469,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun launchFlooded(f: PendingFlood) {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy()) { flood = null; floodProgress = 0f; return }
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || protons.protonBusyId != null ||
|
||||
ProtonExtras.installInProgress || pkgStage != null) {
|
||||
flood = null
|
||||
floodProgress = 0f
|
||||
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
|
||||
return
|
||||
}
|
||||
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
|
||||
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
|
||||
overridePendingTransition(0, 0)
|
||||
@@ -544,12 +559,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
return true
|
||||
}
|
||||
return startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.returnTo(returnHome)
|
||||
.apply {
|
||||
if (game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID) {
|
||||
putExtra(SessionActivity.EXTRA_STEAMVR, true)
|
||||
@@ -560,6 +575,12 @@ class MainActivity : ComponentActivity() {
|
||||
}, steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.returnTo(returnHome: Boolean): Intent {
|
||||
val task = taskId
|
||||
return putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.apply { if (returnHome) putExtra(SessionActivity.EXTRA_LINK_TASK, task) }
|
||||
}
|
||||
|
||||
private fun launchWithoutSteam(game: Library.SteamGame, returnHome: Boolean): Boolean {
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
@@ -573,16 +594,18 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
com.droiddeck.launcher.session.NoSteamLaunch.remember(command)
|
||||
val next = Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.returnTo(returnHome)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
|
||||
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
@@ -677,7 +700,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun startSessionOrWait(next: Intent): Boolean {
|
||||
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL))
|
||||
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL) ||
|
||||
next.getStringExtra(SessionService.EXTRA_PROGRAM) in setOf(com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET))
|
||||
if (!started && (busy || LinuxRuntimeInstaller.isBusy())) android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return started
|
||||
}
|
||||
@@ -699,7 +723,7 @@ class MainActivity : ComponentActivity() {
|
||||
else {
|
||||
switchPoll = null
|
||||
switchingTo = null
|
||||
android.widget.Toast.makeText(this@MainActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this@FrontEndActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -742,6 +766,7 @@ class MainActivity : ComponentActivity() {
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
if (!SessionState.running) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
|
||||
theme = SessionPrefs.theme(this)
|
||||
frameGenLabel = getString(R.string.frame_gen_off)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
@@ -755,12 +780,13 @@ class MainActivity : ComponentActivity() {
|
||||
downloadSource = DownloadSource.mode(this)
|
||||
downloadMirror = DownloadSource.mirror(this)
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
downloadProxy = DownloadSource.proxyHostPort(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
LauncherIcon.repair(this, intent?.component)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
storesShowMature = SessionPrefs.storesShowMature(this)
|
||||
storesSort = SessionPrefs.storesSort(this)
|
||||
storesView = SessionPrefs.storesView(this)
|
||||
@@ -790,6 +816,7 @@ class MainActivity : ComponentActivity() {
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
|
||||
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
|
||||
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
@@ -808,6 +835,7 @@ class MainActivity : ComponentActivity() {
|
||||
packageStage = pkgStage,
|
||||
packagePercent = pkgPercent,
|
||||
sessionRunning = SessionState.running,
|
||||
battleNetInstalled = battleNetInstalled,
|
||||
backActionsInverted = backActionsInverted,
|
||||
buildLabel = BuildConfig.BUILD_LABEL,
|
||||
oscMode = oscMode,
|
||||
@@ -820,11 +848,11 @@ class MainActivity : ComponentActivity() {
|
||||
downloadSource = downloadSource,
|
||||
downloadMirror = downloadMirror,
|
||||
downloadPrefix = downloadPrefix,
|
||||
downloadProxy = downloadProxy,
|
||||
animationsEnabled = animationsEnabled,
|
||||
launcherIcon = launcherIcon,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
navigate = navRequest,
|
||||
addedGamesArt = addedGamesArt,
|
||||
sgdbUserKey = sgdbUserKey,
|
||||
@@ -896,6 +924,7 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, app.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, app.args.toTypedArray()))
|
||||
},
|
||||
onBattleNet = { openBattleNet() },
|
||||
onFlatpakApp = { id, name ->
|
||||
Library.flatpakNames[id] = name
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
@@ -950,6 +979,12 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
},
|
||||
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
|
||||
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
|
||||
onBackgroundAppLimit = { limit ->
|
||||
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
|
||||
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -999,7 +1034,6 @@ class MainActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
|
||||
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
|
||||
@@ -1022,6 +1056,10 @@ class MainActivity : ComponentActivity() {
|
||||
DownloadSource.setCustom(this, prefix)
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
},
|
||||
onDownloadProxy = { value ->
|
||||
DownloadSource.setProxy(this, value)
|
||||
downloadProxy = DownloadSource.proxyHostPort(this)
|
||||
},
|
||||
onHomeApp = { manageHomeApp() },
|
||||
onHomeScreen = { on ->
|
||||
HomeApp.setHomeScreenEnabled(this, on)
|
||||
@@ -1037,7 +1075,7 @@ class MainActivity : ComponentActivity() {
|
||||
onWirelessAdbPair = { host, port, code, complete ->
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
|
||||
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@FrontEndActivity, host, port, code) }
|
||||
}
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
|
||||
ui.post { complete(error) }
|
||||
@@ -1045,13 +1083,13 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onFindWirelessAdbPort = { host, complete ->
|
||||
Thread({
|
||||
val port = WirelessAdbFix.findConnectPort(this@MainActivity, host)
|
||||
val port = WirelessAdbFix.findConnectPort(this@FrontEndActivity, host)
|
||||
ui.post { complete(port) }
|
||||
}, "wireless-adb-discovery").start()
|
||||
},
|
||||
onWirelessAdbApply = { host, port, enabled, complete ->
|
||||
Thread({
|
||||
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
|
||||
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@FrontEndActivity, host, port, enabled) }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
|
||||
ui.post {
|
||||
if (error == null) refreshPhantomStatus()
|
||||
@@ -1061,7 +1099,7 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onSetPhantomProcessLimit = { enabled, complete ->
|
||||
Thread({
|
||||
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
|
||||
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@FrontEndActivity, enabled) }
|
||||
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
|
||||
ui.post {
|
||||
if (error == null) refreshPhantomStatus()
|
||||
@@ -1117,7 +1155,7 @@ class MainActivity : ComponentActivity() {
|
||||
showNonAdreno?.let { release ->
|
||||
ConfirmDialog(
|
||||
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@MainActivity), release.size / 1e6),
|
||||
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@FrontEndActivity), release.size / 1e6),
|
||||
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
|
||||
onConfirm = { showNonAdreno = null; install(release) },
|
||||
onDismiss = { showNonAdreno = null },
|
||||
@@ -1263,6 +1301,23 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
com.droiddeck.launcher.ui.Ambient.poke()
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(event: android.view.MotionEvent): Boolean {
|
||||
if (event.actionMasked == android.view.MotionEvent.ACTION_UP || event.actionMasked == android.view.MotionEvent.ACTION_CANCEL) {
|
||||
com.droiddeck.launcher.ui.Ambient.poke()
|
||||
}
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: android.view.MotionEvent): Boolean {
|
||||
com.droiddeck.launcher.ui.Ambient.poke()
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
@@ -1287,7 +1342,9 @@ class MainActivity : ComponentActivity() {
|
||||
refreshWifiDiscovery()
|
||||
com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = { args -> openBattleNet(args) }
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
com.droiddeck.launcher.ui.Ambient.poke()
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
|
||||
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
|
||||
}
|
||||
@@ -1353,6 +1410,7 @@ class MainActivity : ComponentActivity() {
|
||||
unregisterReceiver(mediaReceiver)
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = null
|
||||
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
flood = null
|
||||
returning = null
|
||||
@@ -1510,6 +1568,7 @@ class MainActivity : ComponentActivity() {
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onMode = { m -> drivers.chooseMode(m) },
|
||||
onRestart = { drivers.restartApp() },
|
||||
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
@@ -1545,6 +1604,50 @@ class MainActivity : ComponentActivity() {
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
}
|
||||
|
||||
private fun openBattleNet(args: List<String> = emptyList()) {
|
||||
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
|
||||
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
val full = if (args.firstOrNull() == "--library") args else com.droiddeck.launcher.stores.battlenet.BattleNetClient.library(app) + args
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
val next = Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, full.toTypedArray())
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
handToBattleNet(full) { pendingSwitch = next } -> {}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "battlenet-open").start()
|
||||
}
|
||||
|
||||
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
|
||||
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
|
||||
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
if (library != null && sessionLibrary != null && library != sessionLibrary) return false
|
||||
val request = if (library != null) args.drop(2) else args
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
if (!handed) otherwise()
|
||||
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
}, "battlenet-request").start()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun launchProgram(path: String) {
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
|
||||
@@ -1610,14 +1713,12 @@ class MainActivity : ComponentActivity() {
|
||||
ModeSettings(
|
||||
mode = mode, resolution = resolution,
|
||||
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, performanceMode = performanceMode,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
zramCompression = if (mode == SessionService.MODE_STEAM) zramCompression else null,
|
||||
zramSupported = zramSupported,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
|
||||
@@ -1637,6 +1738,7 @@ class MainActivity : ComponentActivity() {
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
|
||||
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
|
||||
shaderPrecache = if (mode == SessionService.MODE_STEAM) shaderPrecache else null,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
@@ -1678,6 +1780,7 @@ class MainActivity : ComponentActivity() {
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
onPerformanceMode = { on -> SessionPrefs.setPerformanceMode(this, mode, on); performanceMode = on },
|
||||
onUpscaler = { m -> SessionPrefs.setUpscaler(this, m); upscaler = m },
|
||||
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
@@ -1686,7 +1789,6 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onZramCompression = { on -> SessionPrefs.setZramCompression(this, on); zramCompression = on },
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
|
||||
onUnfoldedControlsWithController = { on ->
|
||||
@@ -1724,9 +1826,15 @@ class MainActivity : ComponentActivity() {
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
|
||||
onSteamClientArch = { id ->
|
||||
SessionPrefs.setSteamClientArch(this, id)
|
||||
steamClientArch = id
|
||||
loadShaderPrecache(mode)
|
||||
refresh()
|
||||
},
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
|
||||
onShaderPrecache = { on -> SessionPrefs.setShaderPrecache(this, on); shaderPrecache = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
@@ -1767,7 +1875,12 @@ class MainActivity : ComponentActivity() {
|
||||
fsyncFirst = fsyncFirst,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
ramCapMb = ramCapMb, vramCapMb = vramCapMb, zramClient = zramClient,
|
||||
deviceRamMb = (android.app.ActivityManager.MemoryInfo()
|
||||
.also((getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)::getMemoryInfo).totalMem / (1024 * 1024)).toInt(),
|
||||
ramCapAvailable = com.droiddeck.launcher.core.ZramSupport.supported(),
|
||||
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, cursorComposite = cursorComposite,
|
||||
guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
|
||||
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
|
||||
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
|
||||
@@ -1779,8 +1892,12 @@ class MainActivity : ComponentActivity() {
|
||||
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
|
||||
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
|
||||
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
|
||||
onRamCapMb = { mb -> SessionPrefs.setRamCapMb(this, mb); ramCapMb = mb },
|
||||
onZramClient = { on -> SessionPrefs.setZramClient(this, on); zramClient = on },
|
||||
onVramCapMb = { mb -> SessionPrefs.setVramCapMb(this, mb); vramCapMb = mb },
|
||||
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
|
||||
onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on },
|
||||
onCursorComposite = { on -> SessionPrefs.setCursorComposite(this, on); cursorComposite = on },
|
||||
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
|
||||
onClientCore = { core, on ->
|
||||
clientCores = if (on) clientCores + core else clientCores - core
|
||||
@@ -1881,6 +1998,19 @@ class MainActivity : ComponentActivity() {
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
private var unfoldedControlsWithController by mutableStateOf(false)
|
||||
private var shaderPrecacheGeneration = 0
|
||||
|
||||
private fun loadShaderPrecache(mode: String) {
|
||||
val generation = ++shaderPrecacheGeneration
|
||||
shaderPrecache = null
|
||||
if (mode != SessionService.MODE_STEAM) return
|
||||
Thread({
|
||||
val precache = SessionPrefs.shaderPrecache(this)
|
||||
ui.post {
|
||||
if (!isDestroyed && generation == shaderPrecacheGeneration) shaderPrecache = precache
|
||||
}
|
||||
}, "shader-precache").start()
|
||||
}
|
||||
|
||||
private fun loadModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
@@ -1896,11 +2026,13 @@ class MainActivity : ComponentActivity() {
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
returnAfterGame = SessionPrefs.returnAfterGame(this)
|
||||
loadShaderPrecache(mode)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
loadBootMovie()
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
performanceMode = SessionPrefs.performanceMode(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
@@ -1918,9 +2050,6 @@ class MainActivity : ComponentActivity() {
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
settingsMode = mode
|
||||
Thread({
|
||||
val zram = com.droiddeck.launcher.core.ZramSupport.supported()
|
||||
val zramOn = SessionPrefs.zramCompression(this)
|
||||
ui.post { zramSupported = zram; zramCompression = zramOn }
|
||||
drivers.refreshDrivers()
|
||||
val storage = GameStorage.options(this)
|
||||
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
|
||||
@@ -1962,9 +2091,13 @@ class MainActivity : ComponentActivity() {
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
|
||||
ramCapMb = SessionPrefs.ramCapMb(this)
|
||||
zramClient = SessionPrefs.zramClient(this)
|
||||
vramCapMb = SessionPrefs.vramCapMb(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
prootFastPath = SessionPrefs.prootFastPath(this)
|
||||
cursorComposite = SessionPrefs.cursorComposite(this)
|
||||
guestHostname = SessionPrefs.guestHostname(this)
|
||||
refreshPhantomStatus()
|
||||
}
|
||||
@@ -1981,6 +2114,7 @@ class MainActivity : ComponentActivity() {
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
battleNetInstalled = com.droiddeck.launcher.runtime.BattleNet.installed(this)
|
||||
offlineAccount = OfflineMode.account(this)
|
||||
offline = OfflineMode.enabled(this)
|
||||
installed = LinuxRuntimeInstaller.installedVersion(this)
|
||||
@@ -1991,6 +2125,10 @@ class MainActivity : ComponentActivity() {
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
|
||||
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
|
||||
clearCachedApps = SessionPrefs.clearCachedApps(this)
|
||||
adbPaired = WirelessAdbFix.hasSavedPairing(this)
|
||||
memoryHud = SessionPrefs.memoryHud(this)
|
||||
backgroundAppLimit = SessionPrefs.backgroundAppLimit(this)
|
||||
runningLabel = if (SessionState.running) when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
|
||||
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
|
||||
@@ -2037,7 +2175,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamGamesX64 = scannedX64
|
||||
steamGames = all.map { g ->
|
||||
val newer = current[g.appId]
|
||||
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
|
||||
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon, newer.logo) else g
|
||||
}
|
||||
shortcutLibraryScanning = false
|
||||
handleGameLink(all)
|
||||
@@ -2088,6 +2226,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refreshPhantomStatus() {
|
||||
phantomProcessStatus = PhantomProcessLimit.read(this)
|
||||
adbPaired = WirelessAdbFix.hasSavedPairing(this)
|
||||
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
getString(
|
||||
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
|
||||
@@ -23,7 +23,7 @@ class HomeActivity : Activity() {
|
||||
.setAction(SessionService.ACTION_HOME_GUIDE)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
} else {
|
||||
Intent(this, MainActivity::class.java)
|
||||
Intent(this, FrontEndActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
startActivity(destination)
|
||||
|
||||
@@ -8,7 +8,7 @@ object LauncherIcon {
|
||||
const val DARK = "dark"
|
||||
const val LIGHT = "light"
|
||||
|
||||
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
|
||||
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.MainActivity", LIGHT to "com.droiddeck.launcher.LauncherLight")
|
||||
|
||||
val options: List<Pair<String, Int>> = listOf(
|
||||
DARK to R.string.setup_launcher_icon_dark,
|
||||
@@ -29,6 +29,12 @@ object LauncherIcon {
|
||||
|
||||
fun activity(context: Context): ComponentName = component(context, current(context))
|
||||
|
||||
fun repair(context: Context, launchedFrom: ComponentName?) {
|
||||
if (!enabled(context, DARK) || !enabled(context, LIGHT) || launchedFrom == component(context, DARK)) return
|
||||
context.packageManager.setComponentEnabledSetting(component(context, DARK),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
|
||||
fun set(context: Context, style: String) {
|
||||
if (style !in aliases || style == current(context)) return
|
||||
val pm = context.packageManager
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -8,31 +10,48 @@ import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.ui.ProtonRow
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.frontend.AutoComponents
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
|
||||
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
internal class ProtonMenu(
|
||||
private val activity: android.app.Activity,
|
||||
private val ui: Handler,
|
||||
private val idle: () -> Boolean = { true },
|
||||
) {
|
||||
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
|
||||
var protonBusyId by mutableStateOf<String?>(null)
|
||||
var protonStage by mutableStateOf<String?>(null)
|
||||
var protonPercent by mutableIntStateOf(-1)
|
||||
private val checking = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
@VisibleForTesting internal var installer: (ProtonExtras.Tool, (String, Int) -> Unit) -> String? =
|
||||
{ tool, progress -> ProtonExtras.install(activity, tool, progress) }
|
||||
|
||||
fun installProton(id: String) {
|
||||
fun installProton(id: String, automatic: Boolean = false) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
if (protonBusyId != null || ProtonExtras.installInProgress || SessionState.running) return
|
||||
ProtonExtras.unqueue(activity, tool)
|
||||
protonBusyId = id
|
||||
protonStage = activity.getString(R.string.store_starting)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
val problem = ProtonExtras.install(activity, tool) { label, value ->
|
||||
ui.post { protonStage = label; protonPercent = value }
|
||||
}
|
||||
val problem = runCatching {
|
||||
installer(tool) { label, value ->
|
||||
ui.post { protonStage = label; protonPercent = value }
|
||||
}
|
||||
}.getOrElse { it.message ?: it.javaClass.simpleName }
|
||||
if (problem == null && !automatic && tool.id == ProtonExtras.OWN) ProtonExtras.setDeclined(activity, false)
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
protonPercent = -1
|
||||
refreshProtons()
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
if (problem != null) {
|
||||
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
|
||||
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
if (automatic) ensureAutoUpdates()
|
||||
}
|
||||
}, "install-proton-$id").start()
|
||||
}
|
||||
@@ -45,6 +64,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir)
|
||||
if (tool.id == ProtonExtras.OWN && ProtonExtras.installedAll(activity, tool).isEmpty()) ProtonExtras.setDeclined(activity, true)
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
@@ -64,7 +84,38 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
if (!checking.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) }
|
||||
if (asked) ui.post { refreshProtons() }
|
||||
ui.post {
|
||||
if (asked) refreshProtons()
|
||||
ensureAutoUpdates()
|
||||
}
|
||||
}, "proton-updates").start()
|
||||
}
|
||||
|
||||
internal fun ensureAutoUpdates(now: Long = System.currentTimeMillis()) {
|
||||
if (activity.isFinishing || activity.isDestroyed || !idle() || protonBusyId != null ||
|
||||
ProtonExtras.installInProgress || SessionState.running || LinuxRuntimeInstaller.isBusy() ||
|
||||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
|
||||
!LinuxRuntime.isInstalled(activity) || !AutoComponents.online(activity) || AutoComponents.unfavourable(activity)) return
|
||||
if (ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == ProtonExtras.OWN }).size > 1) {
|
||||
protonBusyId = ProtonExtras.OWN
|
||||
Thread({
|
||||
ProtonExtras.tidy(activity)
|
||||
val tidied = ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == ProtonExtras.OWN }).size <= 1
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
refreshProtons()
|
||||
if (tidied) ensureAutoUpdates()
|
||||
}
|
||||
}, "proton-tidy").start()
|
||||
return
|
||||
}
|
||||
val attempts = activity.getSharedPreferences("proton-auto-updates", android.content.Context.MODE_PRIVATE)
|
||||
val (tool, update) = ProtonExtras.tools.firstNotNullOfOrNull { tool ->
|
||||
val update = ProtonExtras.update(activity, tool) ?: ProtonExtras.missing(activity, tool) ?: return@firstNotNullOfOrNull null
|
||||
val previous = attempts.getLong("${tool.id}.at", 0L)
|
||||
if (attempts.getString("${tool.id}.tag", null) != update || now - previous !in 0 until 15L * 60 * 1000) tool to update else null
|
||||
} ?: return
|
||||
attempts.edit().putString("${tool.id}.tag", update).putLong("${tool.id}.at", now).apply()
|
||||
installProton(tool.id, automatic = true)
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,10 @@ import com.droiddeck.launcher.input.OnScreenControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadMouse
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.input.ScreenKeys
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.DirectTouchHold
|
||||
import com.droiddeck.launcher.input.GestureTouchStreamGate
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
@@ -155,6 +157,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var sessionOverlay: ComposeView
|
||||
private lateinit var loading: LoadingState
|
||||
private lateinit var hud: PerfHud
|
||||
private lateinit var memoryHud: com.droiddeck.launcher.session.MemoryHud
|
||||
private var padBridge: PadBridge? = null
|
||||
private var joyConVr: com.droiddeck.launcher.input.JoyConVr? = null
|
||||
private var handCamera: com.droiddeck.launcher.handtracking.HandCamera? = null
|
||||
@@ -198,10 +201,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
|
||||
private var gestureGame: String? = null
|
||||
private var gestureSettingsOpen by mutableStateOf(false)
|
||||
private var screenKeyPickerOpen by mutableStateOf(false)
|
||||
private var screenKeyBind: List<Int>? = null
|
||||
private var screenKeyDir: Int? = null
|
||||
private var screenKeyTextFocus = false
|
||||
private var screenKeyEdit by mutableStateOf<com.droiddeck.launcher.input.ScreenKey?>(null)
|
||||
private var screenLayoutsOpen by mutableStateOf(false)
|
||||
private var screenKeysShown by mutableStateOf(true)
|
||||
private var screenLayoutNames by mutableStateOf(listOf<String>())
|
||||
private var screenKeys: ScreenKeys? = null
|
||||
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
|
||||
private var gesturePointerX = 0f
|
||||
private var gesturePointerY = 0f
|
||||
private var gesturePointerReady = false
|
||||
private val gestureTouchStreamGate = GestureTouchStreamGate()
|
||||
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
|
||||
private val gestureKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var scrollTouch: ScrollTouchGestures
|
||||
@@ -270,6 +283,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
private val resumeKeysDown = mutableSetOf<Pair<Int, Int>>()
|
||||
private val drawerShortcutKeysDown = mutableSetOf<Pair<Int, Int>>()
|
||||
|
||||
private var drawerOpen by mutableStateOf(false)
|
||||
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
|
||||
@@ -280,6 +294,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var drawerDefaultProton by mutableStateOf<String?>(null)
|
||||
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
|
||||
private var drawerGameProfileId by mutableStateOf<Long?>(null)
|
||||
|
||||
private fun noSteamSession(): Boolean = SessionState.mode == SessionService.MODE_RUN &&
|
||||
SessionState.program in setOf(com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.EPIC)
|
||||
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
|
||||
private var drawerGameInventoryRevision = 0L
|
||||
private var drawerGameFexPresetSaving by mutableStateOf(false)
|
||||
@@ -329,6 +346,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var glassesSettings by mutableStateOf(Glasses.settings.value)
|
||||
private var glassesScreen: GlassesScreen? = null
|
||||
private var glassesOutputActive by mutableStateOf(false)
|
||||
private var pausedFrame by mutableStateOf<com.droiddeck.launcher.ui.PausedFrame?>(null)
|
||||
private var pausedFrameWanted = false
|
||||
private var trackingRecovery by mutableStateOf<TrackingRecovery?>(null)
|
||||
private var glasses3dForVr = false
|
||||
private var ended = false
|
||||
@@ -473,9 +492,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.gpu.SteamVrHealth.begin(this, SessionState.sessionId)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (!SessionState.running) {
|
||||
SessionState.fpsLimit = SessionPrefs.fpsLimit(
|
||||
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
|
||||
)
|
||||
val prefMode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
SessionState.fpsLimit = SessionPrefs.fpsLimit(this, prefMode)
|
||||
SessionState.performanceMode = SessionPrefs.performanceMode(this, prefMode)
|
||||
}
|
||||
applyOutputPacing()
|
||||
if (!SessionState.running) SessionState.followScreen = followChoice()
|
||||
@@ -532,6 +551,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
cursorVisible = false
|
||||
}
|
||||
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
|
||||
screenKeysShown = com.droiddeck.launcher.input.ControllerPrefs.screenKeysShown(this)
|
||||
screenKeys = ScreenKeys(this) { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) }.also { keys ->
|
||||
keys.onTouchMiss = miss@{ event ->
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES &&
|
||||
!gestureTouchStreamGate.accepts(GestureTouchStreamGate.Source.SCREEN_KEYS, event)) return@miss
|
||||
event.offsetLocation(keys.left - surfaceView.left.toFloat(), keys.top - surfaceView.top.toFloat())
|
||||
onTouchEvent(event)
|
||||
}
|
||||
keys.onAdd = { screenKeyPickerOpen = true }
|
||||
keys.onSelect = { codes -> screenKeyEdit = screenKeys?.stateOf(codes) }
|
||||
keys.onEditingChanged = { if (!it) screenKeyEdit = null }
|
||||
keys.steamStyle = { steamTouchControls?.style }
|
||||
keys.visibility = if (screenKeysShown) View.VISIBLE else View.GONE
|
||||
root.addView(keys)
|
||||
}
|
||||
keyboard = KeyboardHost(this).also { root.addView(it) }
|
||||
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
||||
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
|
||||
@@ -546,7 +580,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// game surface and on-screen buttons, in either finger-down order.
|
||||
root.isMotionEventSplittingEnabled = true
|
||||
surfaceView.setOnTouchListener { _, event ->
|
||||
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
|
||||
if (routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
|
||||
gestureTouchStreamGate.reset()
|
||||
false
|
||||
} else if (!gestureTouchStreamGate.accepts(GestureTouchStreamGate.Source.SURFACE, event)) {
|
||||
false
|
||||
} else onTouchEvent(event)
|
||||
}
|
||||
padMouse = PadMouse(padMouseListener)
|
||||
bridge.setTouchRoute(object : PadBridge.TouchRoute {
|
||||
@@ -570,17 +609,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
|
||||
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
|
||||
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
|
||||
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
|
||||
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
val protonUser = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM ||
|
||||
program == com.droiddeck.launcher.runtime.BattleNet.PROGRAM || program == com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN
|
||||
val needProton = protonUser && com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
|
||||
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
|
||||
}
|
||||
hud = PerfHud(this)
|
||||
memoryHud = com.droiddeck.launcher.session.MemoryHud(this)
|
||||
hud.onPresentingWindowChanged = {
|
||||
if (FrameGen.engine(this) != FrameGen.ENGINE_OFF) CompositorHost.rearmFrameGen { applyFrameGen() }
|
||||
}
|
||||
if (SessionState.running && SessionState.firstFrameSeen) {
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
memoryHud.start()
|
||||
}
|
||||
readPrefs()
|
||||
|
||||
@@ -675,7 +718,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
|
||||
val hudLines = listOf(hud.text, memoryHud.text).filter { it.isNotEmpty() }
|
||||
if (hudLines.isNotEmpty() || hud.graph.size >= 2)
|
||||
HudText(hudLines.joinToString("\n"), hud.graph, hudColor, hudOpacity)
|
||||
val phonePanel = glassesOutputActive && !loading.visible && !drawerOpen && !pcKeyboardOpen
|
||||
if (phonePanel) GlassesPhonePanel(glasses, steamVrStatus == SteamVr.Status.RUNNING, headTracking)
|
||||
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
|
||||
@@ -731,6 +776,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
|
||||
cancelTouchGestures()
|
||||
screenKeys?.stopEditing()
|
||||
}
|
||||
}
|
||||
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
|
||||
@@ -739,6 +785,84 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onDismiss = { gestureSettingsOpen = false },
|
||||
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
|
||||
)
|
||||
if (screenKeyPickerOpen) {
|
||||
val dir = screenKeyDir
|
||||
com.droiddeck.launcher.ui.ScreenKeyPicker(
|
||||
initial = if (dir != null) {
|
||||
listOfNotNull((screenKeyEdit?.codes?.getOrNull(dir) ?: 0).takeIf { it != 0 })
|
||||
} else {
|
||||
screenKeyBind ?: emptyList()
|
||||
},
|
||||
onDismiss = { screenKeyPickerOpen = false; screenKeyBind = null; screenKeyDir = null },
|
||||
onAdd = { codes ->
|
||||
val bind = screenKeyBind
|
||||
if (dir != null && bind != null) {
|
||||
screenKeys?.setDirection(bind, dir, codes.lastOrNull() ?: 0)?.let { screenKeyEdit = it }
|
||||
} else if (bind != null) {
|
||||
screenKeys?.setCodes(bind, codes)?.let { screenKeyEdit = it }
|
||||
} else {
|
||||
screenKeys?.add(codes)
|
||||
screenKeyEdit = screenKeys?.stateOf(codes)
|
||||
}
|
||||
screenKeyPickerOpen = false
|
||||
screenKeyBind = null
|
||||
screenKeyDir = null
|
||||
},
|
||||
)
|
||||
}
|
||||
screenKeyEdit?.let { key ->
|
||||
com.droiddeck.launcher.ui.ScreenKeyEditor(
|
||||
codes = key.codes, keyY = key.y,
|
||||
size = key.size, opacity = key.opacity, shape = key.shape, toggle = key.toggle,
|
||||
labels = key.labels,
|
||||
text = key.text, hasImage = key.image.isNotEmpty(),
|
||||
onResize = { screenKeys?.resize(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onOpacity = { screenKeys?.setOpacity(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onShape = { screenKeys?.setShape(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onToggle = { on -> screenKeys?.setToggle(key.codes, on); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onLabels = { on -> screenKeys?.setLabels(key.codes, on); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onDirection = { index -> screenKeyBind = key.codes; screenKeyDir = index; screenKeyPickerOpen = true },
|
||||
onText = { screenKeys?.setText(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onImage = { screenKeys?.setImage(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onClearImage = { screenKeys?.setImage(key.codes, ""); screenKeyEdit = screenKeys?.stateOf(key.codes) },
|
||||
onBind = { screenKeyBind = key.codes; screenKeyDir = null; screenKeyPickerOpen = true },
|
||||
onTextFocus = { screenKeyTextFocus = true; keyboard?.hide() },
|
||||
onTextBlur = { screenKeyTextFocus = false },
|
||||
onRemove = { screenKeys?.remove(key.codes); screenKeyEdit = null },
|
||||
onDismiss = { screenKeyEdit = null; screenKeyTextFocus = false; screenKeys?.deselect() },
|
||||
)
|
||||
}
|
||||
if (screenLayoutsOpen) com.droiddeck.launcher.ui.ScreenLayoutPanel(
|
||||
layouts = screenLayoutNames,
|
||||
onSave = { name ->
|
||||
screenKeys?.let { keys ->
|
||||
com.droiddeck.launcher.input.ControllerPrefs.saveScreenLayout(this@SessionActivity, name, keys.export())
|
||||
}
|
||||
screenLayoutNames = layoutNames()
|
||||
},
|
||||
onLoad = { name ->
|
||||
com.droiddeck.launcher.input.ControllerPrefs.screenLayouts(this@SessionActivity)[name]?.let { screenKeys?.load(it) }
|
||||
screenLayoutsOpen = false
|
||||
},
|
||||
onDelete = { name ->
|
||||
com.droiddeck.launcher.input.ControllerPrefs.deleteScreenLayout(this@SessionActivity, name)
|
||||
screenLayoutNames = layoutNames()
|
||||
},
|
||||
onImport = { text ->
|
||||
val width = (screenKeys?.width ?: 0).takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
|
||||
val keys = com.droiddeck.launcher.input.importScreenLayout(text, width, resources.displayMetrics.density)
|
||||
if (keys != null) {
|
||||
screenKeys?.load(com.droiddeck.launcher.input.ControllerPrefs.encodeScreenKeys(keys))
|
||||
screenLayoutsOpen = false
|
||||
}
|
||||
},
|
||||
exportText = {
|
||||
val width = (screenKeys?.width ?: 0).takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
|
||||
val keys = com.droiddeck.launcher.input.ControllerPrefs.decodeScreenKeys(screenKeys?.export().orEmpty())
|
||||
com.droiddeck.launcher.input.exportScreenLayout(keys, width, resources.displayMetrics.density)
|
||||
},
|
||||
onDismiss = { screenLayoutsOpen = false },
|
||||
)
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
@@ -785,6 +909,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
)
|
||||
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
|
||||
steam = SessionState.mode == SessionService.MODE_STEAM,
|
||||
direct = noSteamSession(),
|
||||
title = if (SessionState.mode == SessionService.MODE_RUN)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null,
|
||||
isHomeApp = isHomeApp,
|
||||
@@ -816,6 +941,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
|
||||
micAvailable = SessionState.micAvailable && !SessionState.suspended,
|
||||
micEnabled = SessionState.micEnabled,
|
||||
onMicEnabled = { SessionService.setMicEnabled(this@SessionActivity, it) },
|
||||
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
@@ -926,6 +1054,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
|
||||
onScreenKeys = { drawerOpen = false; screenKeys?.startEditing() },
|
||||
screenKeysShown = screenKeysShown,
|
||||
onScreenKeysShown = { shown ->
|
||||
screenKeysShown = shown
|
||||
com.droiddeck.launcher.input.ControllerPrefs.setScreenKeysShown(this@SessionActivity, shown)
|
||||
screenKeys?.stopEditing()
|
||||
screenKeys?.visibility = if (shown) View.VISIBLE else View.GONE
|
||||
},
|
||||
onScreenLayouts = {
|
||||
drawerOpen = false
|
||||
screenLayoutNames = layoutNames()
|
||||
screenLayoutsOpen = true
|
||||
},
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
@@ -1013,9 +1154,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
|
||||
}
|
||||
com.droiddeck.launcher.ui.AchievementNotificationOverlay()
|
||||
if (SessionState.suspended) SessionPausedOverlay(
|
||||
// Auto and Native resume on their own as the session comes back; only a failed resume needs the button.
|
||||
val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE
|
||||
SessionPausedOverlay(
|
||||
suspended = SessionState.suspended && (!autoResume || SessionState.resumeFailed),
|
||||
engaged = SessionState.resumeRequested || SessionState.resuming,
|
||||
failures = SessionState.resumeFailures,
|
||||
frame = pausedFrame,
|
||||
title = pausedTitle(),
|
||||
onResume = { SessionService.resume(this@SessionActivity) },
|
||||
onFrameNeeded = ::requestPausedFrame,
|
||||
onHidden = {
|
||||
pausedFrame = null
|
||||
pausedFrameWanted = false
|
||||
},
|
||||
)
|
||||
if (leaving) com.droiddeck.launcher.ui.FloodSpread(
|
||||
com.droiddeck.launcher.ui.LocalPalette.current.signal,
|
||||
@@ -1225,6 +1377,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds)
|
||||
val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) }
|
||||
AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds)
|
||||
?: SessionState.gameId?.toLongOrNull()
|
||||
?: refresh.requestedAppId
|
||||
} else {
|
||||
refresh.requestedAppId
|
||||
@@ -1471,7 +1624,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
|
||||
}
|
||||
touchMode = nextTouchMode
|
||||
routedTouchMode = effectiveTouchMode()
|
||||
val nextRoutedTouchMode = effectiveTouchMode()
|
||||
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
|
||||
routedTouchMode = nextRoutedTouchMode
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
@@ -1693,7 +1848,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "compositor.start driver=$driverId")
|
||||
CompositorHost.startOrAttach(
|
||||
holder.surface, runtimeDir.path,
|
||||
this, holder.surface, runtimeDir.path,
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(),
|
||||
)
|
||||
@@ -1711,6 +1866,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
|
||||
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
|
||||
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
|
||||
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
|
||||
sessionActivityDisplayId,
|
||||
)
|
||||
}
|
||||
@@ -1827,7 +1983,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun mirrorRefreshLimit(): Int =
|
||||
GlassesRendering.mirrorRefreshLimit(glassesSettings, glasses, glassesScreen != null)
|
||||
|
||||
private fun outputFpsLimit(): Int = GlassesRendering.fpsLimit(SessionState.fpsLimit, mirrorRefreshLimit())
|
||||
private fun outputFpsLimit(): Int = SessionPrefs.compositorFpsLimit(
|
||||
GlassesRendering.fpsLimit(SessionState.fpsLimit, mirrorRefreshLimit()), SessionState.performanceMode,
|
||||
)
|
||||
|
||||
private fun applyOutputPacing() {
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, outputFpsLimit(), mirrorRefreshLimit()))
|
||||
@@ -1880,7 +2038,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun run() {
|
||||
if (!watching) return
|
||||
if (::pip.isInitialized) pip.refresh()
|
||||
if (SessionState.stopRequested && !SessionState.running && !installingRuntime) {
|
||||
if (SessionState.stopRequested && !SessionState.running && !installingRuntime &&
|
||||
SessionState.phase != SessionPhase.STOPPING) {
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.transition(SessionPhase.IDLE, "session.cancelled")
|
||||
collectStartArtifacts("session start cancelled")
|
||||
@@ -1913,6 +2072,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
|
||||
setIntent(next)
|
||||
recreate()
|
||||
return@runOnUiThread
|
||||
@@ -1944,7 +2104,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
repairOffered = hint?.repair == true
|
||||
loading.showEnded(
|
||||
hint?.text ?: stopped,
|
||||
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
|
||||
getString(R.string.session_ended_detail, status,
|
||||
SessionState.logFile?.path?.let { android.text.BidiFormatter.getInstance().unicodeWrap(it, android.text.TextDirectionHeuristics.LTR) }
|
||||
?: getString(R.string.session_ended_no_log)),
|
||||
)
|
||||
focusEndedScreen()
|
||||
}
|
||||
@@ -2023,6 +2185,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (pipUi) return true
|
||||
if (screenKeyTextFocus) return super.dispatchKeyEvent(event)
|
||||
val pad = com.droiddeck.launcher.input.JoyCon.normalize(event)
|
||||
if (pad !== event) return dispatchKeyEvent(pad)
|
||||
val fromController = PadBridge.isControllerKeyEvent(event)
|
||||
@@ -2044,6 +2207,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
val drawerShortcutKey = event.deviceId to event.keyCode
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT || event.keyCode == KeyEvent.KEYCODE_BUTTON_START) &&
|
||||
((drawerOpen && SessionState.mode == SessionService.MODE_STEAM) || drawerShortcutKey in drawerShortcutKeysDown)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
drawerShortcutKeysDown.add(drawerShortcutKey)
|
||||
if (event.repeatCount == 0) {
|
||||
releaseDrawerDirection()
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) sendSteamGuide() else triggerSteamQam()
|
||||
}
|
||||
} else if (event.action == KeyEvent.ACTION_UP) {
|
||||
drawerShortcutKeysDown.remove(drawerShortcutKey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (drawerOpen) {
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
@@ -2210,9 +2387,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun layoutNames(): List<String> =
|
||||
com.droiddeck.launcher.input.ControllerPrefs.screenLayouts(this).keys.sorted()
|
||||
|
||||
private fun refreshGestureConfig() {
|
||||
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
|
||||
gestureTouchStreamGate.reset()
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
|
||||
if (::directTouchHold.isInitialized) directTouchHold.configure(gestureConfig)
|
||||
gesturePointerReady = false
|
||||
recognizedGesture = null
|
||||
}
|
||||
@@ -2500,6 +2682,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun refreshControls() {
|
||||
applyUnfoldedControls()
|
||||
updateOnScreenControls()
|
||||
screenKeys?.refreshAppearance()
|
||||
}
|
||||
|
||||
private fun applyUnfoldedControls() {
|
||||
@@ -2585,6 +2768,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
gestureTouchStreamGate.reset()
|
||||
routedTouchMode = next
|
||||
}
|
||||
|
||||
@@ -2775,6 +2959,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
device.plugged = false
|
||||
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
|
||||
}
|
||||
view.onStyle = { screenKeys?.refreshAppearance() }
|
||||
root.addView(view, root.indexOfChild(onScreenControls) + 1)
|
||||
steamTouchControls = view
|
||||
steamTouchAvailable = true
|
||||
@@ -2805,6 +2990,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
screenKeys?.followSteam = false
|
||||
applyTouchRouting()
|
||||
return
|
||||
}
|
||||
@@ -2833,6 +3019,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenButtonsVisible = show
|
||||
val drawn = show && !keyboardCoversPad()
|
||||
if (steam != null) {
|
||||
screenKeys?.followSteam = drawn
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
if (!drawn) steam.releaseAll()
|
||||
steam.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
@@ -2841,6 +3028,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
|
||||
return
|
||||
}
|
||||
screenKeys?.followSteam = false
|
||||
if (drawn == (controls.visibility == View.VISIBLE)) return
|
||||
if (!drawn) controls.releaseAll()
|
||||
controls.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
@@ -2860,6 +3048,50 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
}
|
||||
|
||||
// The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame.
|
||||
private fun capturePausedFrame() {
|
||||
if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return
|
||||
if (SessionState.suspended && pausedFrame != null) return
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_NEVER || !::surfaceView.isInitialized) return
|
||||
if (surfaceView.holder.surface?.isValid != true) return
|
||||
val width = surfaceView.width
|
||||
val height = surfaceView.height
|
||||
if (width <= 0 || height <= 0) return
|
||||
val scale = minOf(1f, PAUSED_FRAME_MAX_EDGE / maxOf(width, height).toFloat())
|
||||
val bitmap = try {
|
||||
android.graphics.Bitmap.createBitmap(
|
||||
maxOf(1, (width * scale).toInt()), maxOf(1, (height * scale).toInt()), android.graphics.Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
} catch (_: OutOfMemoryError) {
|
||||
return
|
||||
}
|
||||
val surfaceAt = IntArray(2).also { surfaceView.getLocationInWindow(it) }
|
||||
val overlayAt = IntArray(2).also { sessionOverlay.getLocationInWindow(it) }
|
||||
val left = surfaceAt[0] - overlayAt[0]
|
||||
val top = surfaceAt[1] - overlayAt[1]
|
||||
val bounds = android.graphics.Rect(left, top, left + width, top + height)
|
||||
try {
|
||||
android.view.PixelCopy.request(surfaceView, bitmap, { result ->
|
||||
if (result == android.view.PixelCopy.SUCCESS && !isDestroyed) {
|
||||
pausedFrame = com.droiddeck.launcher.ui.PausedFrame(bitmap.asImageBitmap(), bounds)
|
||||
pausedFrameWanted = false
|
||||
} else {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}, uiHandler)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the
|
||||
// compositor shows the frozen frame again once its output is back, so copy it from there.
|
||||
private fun requestPausedFrame() {
|
||||
if (pausedFrame != null) return
|
||||
pausedFrameWanted = true
|
||||
if (resumed && surfaceW > 0) capturePausedFrame()
|
||||
}
|
||||
|
||||
private fun pipBounds(): android.graphics.Rect {
|
||||
val rect = android.graphics.Rect()
|
||||
if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect
|
||||
@@ -2882,6 +3114,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
pendingBackAction = null
|
||||
releaseDrawerDirection()
|
||||
onScreenControls?.releaseAll()
|
||||
screenKeys?.releaseAll()
|
||||
releasePads()
|
||||
touchpad.cancel()
|
||||
scrollTouch.cancel()
|
||||
@@ -2970,6 +3203,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
|
||||
if (!intent.hasExtra(EXTRA_LINK_TASK)) intent.putExtra(EXTRA_LINK_TASK, this.intent.getIntExtra(EXTRA_LINK_TASK, -1))
|
||||
setIntent(intent)
|
||||
if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) {
|
||||
SessionState.steamVr = true
|
||||
@@ -2990,8 +3224,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
applyFollowOrientation(resources.configuration)
|
||||
if (runtimeRemovalBlocked) return
|
||||
if (!SessionState.suspended) pausedFrame = null
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
if (::memoryHud.isInitialized && SessionState.running && SessionState.firstFrameSeen) memoryHud.refresh()
|
||||
if (intent?.action == SessionService.ACTION_RESUME) {
|
||||
intent.action = null
|
||||
SessionService.resume(this)
|
||||
@@ -3015,6 +3251,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onPause() {
|
||||
if (runtimeRemovalBlocked) { super.onPause(); return }
|
||||
capturePausedFrame()
|
||||
sessionClipboard.stop()
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
@@ -3253,6 +3490,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE
|
||||
Log.i(TAG, "compositor output attached: " +
|
||||
if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone")
|
||||
if (pausedFrameWanted) uiHandler.postDelayed({
|
||||
if (pausedFrameWanted && pausedFrame == null && resumed) capturePausedFrame()
|
||||
}, PAUSED_FRAME_REDRAW_MS)
|
||||
}
|
||||
|
||||
private fun restorePhoneOutput(screen: GlassesScreen) {
|
||||
@@ -3433,8 +3673,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.firstFrame()
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
memoryHud.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3452,6 +3694,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
glassesScreen?.let { screen -> glassesScreen = null; runCatching { screen.dismiss() } }
|
||||
headTracker?.stop()
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
if (::memoryHud.isInitialized) memoryHud.stop()
|
||||
joyConVr?.close()
|
||||
joyConVr = null
|
||||
handCamera?.stop()
|
||||
@@ -3487,17 +3730,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return
|
||||
}
|
||||
super.finish()
|
||||
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()
|
||||
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
closeLinkTasks()
|
||||
if (!isTaskRoot) moveTaskToBack(true)
|
||||
super.finishAndRemoveTask()
|
||||
} else super.finish()
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
|
||||
private fun closeLinkTasks() {
|
||||
val linkTask = intent.getIntExtra(EXTRA_LINK_TASK, -1)
|
||||
if (linkTask == -1) return
|
||||
val tasks = runCatching { getSystemService(android.app.ActivityManager::class.java)?.appTasks }.getOrNull().orEmpty()
|
||||
for (task in tasks) {
|
||||
runCatching {
|
||||
val info = task.taskInfo
|
||||
@Suppress("DEPRECATION")
|
||||
val id = if (android.os.Build.VERSION.SDK_INT >= 29) info.taskId else info.persistentId
|
||||
val base = info.baseIntent
|
||||
if (id == linkTask && base.component?.className == FrontEndActivity::class.java.name &&
|
||||
com.droiddeck.launcher.frontend.GameLaunchIntent.accepts(base.action)) task.finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeLeave() {
|
||||
if (isFinishing) return
|
||||
val signal = com.droiddeck.launcher.ui.Themes.current(this).signal
|
||||
@@ -3509,9 +3768,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
const val EXTRA_LINK_TASK = "linkTask"
|
||||
const val EXTRA_STEAMVR = "steamVr"
|
||||
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val PAUSED_FRAME_MAX_EDGE = 1920f
|
||||
private const val PAUSED_FRAME_REDRAW_MS = 150L
|
||||
private const val TAG = "SessionActivity"
|
||||
private var startedScreens = 0
|
||||
private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need"
|
||||
|
||||
@@ -29,6 +29,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
private var pending: AppUpdates.Release? = null
|
||||
private var lastCheck = 0L
|
||||
private var request = 0
|
||||
@Volatile private var session = -1
|
||||
|
||||
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
|
||||
|
||||
@@ -87,13 +88,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
session = -1
|
||||
stage = activity.getString(R.string.upd_stage_downloading)
|
||||
percent = 0
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
|
||||
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
|
||||
SelfInstaller.install(activity, file)
|
||||
SelfInstaller.install(activity, file) { id -> session = id }
|
||||
}
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
|
||||
}, "app-update-install").start()
|
||||
@@ -101,13 +103,15 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1).let { it < 0 || it != session }) return
|
||||
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
|
||||
val safe = confirm?.let { SelfInstaller.safeConfirmIntent(activity, it) }
|
||||
if (safe == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
|
||||
stage = activity.getString(R.string.upd_stage_confirm)
|
||||
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
if (runCatching { activity.startActivity(safe) }.isFailure) { stage = null; error = activity.getString(R.string.upd_not_offered) }
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> stage = null
|
||||
else -> {
|
||||
|
||||
@@ -3,9 +3,13 @@ package com.droiddeck.launcher.agent
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
@@ -55,6 +59,9 @@ object AgentEnv {
|
||||
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
|
||||
if (name == "libblfastpath.so" && (SessionState.running || SessionState.guestPid > 0 || SessionState.phase == SessionPhase.STOPPING)) {
|
||||
throw AgentException("SESSION_ACTIVE", "Stop the session before installing a fast-path library")
|
||||
}
|
||||
val source = File(inbox(context), name)
|
||||
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
|
||||
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
|
||||
@@ -67,6 +74,10 @@ object AgentEnv {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
|
||||
}
|
||||
if (name == "libblfastpath.so" && !arm64SharedLibrary(staged)) {
|
||||
staged.delete()
|
||||
throw AgentException("INVALID_LIBRARY", "The fast path must be a 64-bit little-endian ARM64 ELF shared library")
|
||||
}
|
||||
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
|
||||
@@ -76,6 +87,40 @@ object AgentEnv {
|
||||
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
|
||||
save(context, state)
|
||||
state.put("installed", "$GUEST_BIN/$name")
|
||||
if (name == "libblfastpath.so") {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
target.inputStream().use { input ->
|
||||
val buffer = ByteArray(65536)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
state.put("sha256", digest.digest().joinToString("") { "%02x".format(it) })
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fun fastPathLibrary(context: Context): String = synchronized(lock) {
|
||||
if (!AgentAccess.commandsAllowed(context)) return@synchronized ProotFastPath.LIBRARY
|
||||
if ("libblfastpath.so" !in strings(load(context).optJSONArray("activeOverrides"))) return@synchronized ProotFastPath.LIBRARY
|
||||
val file = File(bin(context), "libblfastpath.so")
|
||||
if (!file.isFile || Files.isSymbolicLink(file.toPath())) return@synchronized ProotFastPath.LIBRARY
|
||||
"$GUEST_BIN/libblfastpath.so"
|
||||
}
|
||||
|
||||
private fun arm64SharedLibrary(file: File): Boolean = file.inputStream().use { input ->
|
||||
val header = ByteArray(20)
|
||||
var count = 0
|
||||
while (count < header.size) {
|
||||
val read = input.read(header, count, header.size - count)
|
||||
if (read < 0) return@use false
|
||||
count += read
|
||||
}
|
||||
header.take(7) == listOf<Byte>(0x7f, 0x45, 0x4c, 0x46, 2, 1, 1) &&
|
||||
header[16] == 3.toByte() && header[17] == 0.toByte() &&
|
||||
header[18] == 183.toByte() && header[19] == 0.toByte()
|
||||
}
|
||||
|
||||
fun beginSession(context: Context): List<String> = synchronized(lock) {
|
||||
@@ -86,13 +131,13 @@ object AgentEnv {
|
||||
val state = load(context)
|
||||
val active = strings(state.optJSONArray("pending"))
|
||||
val overrides = strings(state.optJSONArray("pendingOverrides"))
|
||||
state.put("active", JSONArray(active)).put("pending", JSONArray())
|
||||
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
|
||||
save(context, state)
|
||||
bin(context).listFiles()
|
||||
?.filter { it.name !in overrides }?.forEach { it.delete() }
|
||||
val lines = strings(state.optJSONArray("persistent")) + active
|
||||
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
|
||||
val withPath = if (overrides.none { it != "libblfastpath.so" }) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
|
||||
state.put("active", JSONArray(active)).put("pending", JSONArray())
|
||||
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
|
||||
save(context, state)
|
||||
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
|
||||
withPath
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.audio;
|
||||
import android.os.Process;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
import com.droiddeck.launcher.core.SessionPart;
|
||||
import com.droiddeck.launcher.core.HostProcess;
|
||||
|
||||
@@ -17,10 +18,15 @@ public class DirectAudioRelayComponent extends SessionPart {
|
||||
private File logFile;
|
||||
private final File micFifoPath;
|
||||
private volatile int pid = -1;
|
||||
private final Object micLock = new Object();
|
||||
private boolean micHot;
|
||||
private final File micControl;
|
||||
public java.util.function.Consumer<Boolean> onMicEnabled;
|
||||
|
||||
public DirectAudioRelayComponent(File socketPath, File micFifoPath) {
|
||||
this.socketPath = socketPath;
|
||||
this.micFifoPath = micFifoPath;
|
||||
this.micControl = micFifoPath == null ? null : new File(micFifoPath.getParentFile(), "mic-enabled");
|
||||
}
|
||||
|
||||
public void setLogFile(File file) {
|
||||
@@ -30,6 +36,9 @@ public class DirectAudioRelayComponent extends SessionPart {
|
||||
@Override
|
||||
public void start() {
|
||||
stop();
|
||||
synchronized (micLock) {
|
||||
micHot = false;
|
||||
}
|
||||
File binary = new File(app().getApplicationInfo().nativeLibraryDir, BINARY);
|
||||
if (!binary.isFile()) {
|
||||
Log.w(TAG, "helper missing at " + binary + "; DirectAudio will report itself unavailable");
|
||||
@@ -58,8 +67,13 @@ public class DirectAudioRelayComponent extends SessionPart {
|
||||
StringBuilder command = new StringBuilder(binary.getAbsolutePath());
|
||||
command.append(" --socket ").append(socketPath.getAbsolutePath());
|
||||
command.append(" --log");
|
||||
if (micFifoPath != null) {
|
||||
final boolean withMic = micFifoPath != null && setMicEnabled(true);
|
||||
if (withMic) {
|
||||
command.append(" --mic-fifo ").append(micFifoPath.getAbsolutePath());
|
||||
command.append(" --mic-control ").append(micControl.getAbsolutePath());
|
||||
} else if (micFifoPath != null) {
|
||||
Log.w(TAG, "microphone control unavailable; starting with capture disabled");
|
||||
command.append(" --mic-control /dev/null");
|
||||
}
|
||||
ArrayList<String> env = new ArrayList<>();
|
||||
env.add("HOME=" + app().getFilesDir());
|
||||
@@ -68,7 +82,7 @@ public class DirectAudioRelayComponent extends SessionPart {
|
||||
try {
|
||||
out = new java.io.PrintWriter(new java.io.FileWriter(logFile, true));
|
||||
out.println("== DirectAudio relay helper starting"
|
||||
+ (micFifoPath != null ? " with the microphone" : " (no microphone)"));
|
||||
+ (withMic ? " with the microphone" : " (no microphone)"));
|
||||
out.flush();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "could not open " + logFile, e);
|
||||
@@ -79,10 +93,47 @@ public class DirectAudioRelayComponent extends SessionPart {
|
||||
app().getFilesDir(), null,
|
||||
line -> {
|
||||
Log.i(TAG, line);
|
||||
if (withMic) trackMic(line);
|
||||
if (log != null) synchronized (log) { log.println(line); log.flush(); }
|
||||
});
|
||||
Log.i(TAG, "started pid=" + pid + " socket=" + socketPath
|
||||
+ (micFifoPath != null ? " mic=" + micFifoPath : " (no microphone)"));
|
||||
+ (withMic ? " mic=" + micFifoPath : " (no microphone)"));
|
||||
}
|
||||
|
||||
public synchronized boolean setMicEnabled(boolean enabled) {
|
||||
if (micControl == null) return false;
|
||||
File next = new File(micControl.getPath() + ".new");
|
||||
return FileUtils.writeString(next, enabled ? "1" : "0") && next.renameTo(micControl);
|
||||
}
|
||||
|
||||
private void trackMic(String line) {
|
||||
if (line.startsWith("DA-Relay: mic enabled=") && onMicEnabled != null) {
|
||||
onMicEnabled.accept(line.equals("DA-Relay: mic enabled=1"));
|
||||
}
|
||||
boolean start = line.startsWith("DA-Relay: mic start ");
|
||||
if (!start && !line.startsWith("DA-Relay: mic stop ")) return;
|
||||
synchronized (micLock) {
|
||||
micHot = start;
|
||||
micLock.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Android keeps the input stream's wake lock while a frozen helper still has it started.
|
||||
public boolean awaitMicReleased(long timeoutMs) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
synchronized (micLock) {
|
||||
while (micHot) {
|
||||
long left = deadline - System.currentTimeMillis();
|
||||
if (left <= 0) return false;
|
||||
try {
|
||||
micLock.wait(left);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.concurrent.TimeUnit;
|
||||
public class PulseAudioComponent extends SessionPart {
|
||||
private static final String TAG = "PulseAudio";
|
||||
public static final String SOCKET_NAME = "PS0";
|
||||
private static final String MIC_SOURCE = "DirectAudioMic";
|
||||
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
|
||||
|
||||
|
||||
@@ -97,9 +98,9 @@ public class PulseAudioComponent extends SessionPart {
|
||||
config.add("set-default-sink DirectAudio");
|
||||
config.add(".fail");
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
config.add("load-module module-pipe-source source_name=DirectAudioMic file=\""
|
||||
config.add("load-module module-pipe-source source_name=" + MIC_SOURCE + " file=\""
|
||||
+ micFifoPath + "\" format=s16le rate=48000 channels=1");
|
||||
config.add("set-default-source DirectAudioMic");
|
||||
config.add("set-default-source " + MIC_SOURCE);
|
||||
}
|
||||
FileUtils.writeString(new File(workingDir, "default.pa"), String.join("\n", config));
|
||||
|
||||
@@ -202,16 +203,24 @@ public class PulseAudioComponent extends SessionPart {
|
||||
}
|
||||
|
||||
public boolean setSinkSuspended(boolean suspended) {
|
||||
return suspend("sink", "@DEFAULT_SINK@", suspended);
|
||||
}
|
||||
|
||||
public boolean setMicSuspended(boolean suspended) {
|
||||
return micFifoPath == null || micFifoPath.isEmpty() || suspend("source", MIC_SOURCE, suspended);
|
||||
}
|
||||
|
||||
private boolean suspend(String kind, String name, boolean suspended) {
|
||||
File pactl = new File(workingDir, "pactl");
|
||||
if (!pactl.isFile() || pid <= 1) {
|
||||
Log.w(TAG, "cannot change sink state: PulseAudio control client or server unavailable");
|
||||
Log.w(TAG, "cannot change " + kind + " state: PulseAudio control client or server unavailable");
|
||||
return false;
|
||||
}
|
||||
java.lang.Process process = null;
|
||||
try {
|
||||
File modules = new File(workingDir, "modules/arm64");
|
||||
ProcessBuilder builder = new ProcessBuilder(
|
||||
pactl.getAbsolutePath(), "suspend-sink", "@DEFAULT_SINK@", Boolean.toString(suspended));
|
||||
pactl.getAbsolutePath(), "suspend-" + kind, name, Boolean.toString(suspended));
|
||||
builder.directory(workingDir);
|
||||
builder.redirectErrorStream(true);
|
||||
builder.redirectOutput(new File("/dev/null"));
|
||||
@@ -222,18 +231,18 @@ public class PulseAudioComponent extends SessionPart {
|
||||
process = builder.start();
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
Log.w(TAG, "timed out changing sink state to suspended=" + suspended);
|
||||
Log.w(TAG, "timed out changing " + kind + " state to suspended=" + suspended);
|
||||
return false;
|
||||
}
|
||||
int status = process.exitValue();
|
||||
if (status != 0) Log.w(TAG, "sink state change failed with exit " + status);
|
||||
if (status != 0) Log.w(TAG, kind + " state change failed with exit " + status);
|
||||
return status == 0;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Log.w(TAG, "interrupted changing sink state", e);
|
||||
Log.w(TAG, "interrupted changing " + kind + " state", e);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "could not change sink state", e);
|
||||
Log.w(TAG, "could not change " + kind + " state", e);
|
||||
return false;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
object BackgroundAppLimit {
|
||||
private const val TAG = "BackgroundAppLimit"
|
||||
const val OFF = -1
|
||||
val CHOICES = listOf(OFF, 4, 1, 0)
|
||||
private const val KEY = "max_cached_processes"
|
||||
private const val STATE = "background-app-limit"
|
||||
private const val APPLIED = "applied"
|
||||
private const val PREVIOUS = "previous"
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { Thread(it, "background-app-limit").apply { isDaemon = true } }
|
||||
|
||||
internal fun readCommand() = "device_config get activity_manager $KEY"
|
||||
internal fun setCommand(limit: Int): String {
|
||||
require(limit in 0..1024)
|
||||
return "device_config put activity_manager $KEY $limit"
|
||||
}
|
||||
internal fun restoreCommand(previous: String?) =
|
||||
if (previous == null) "device_config delete activity_manager $KEY" else "device_config put activity_manager $KEY $previous"
|
||||
|
||||
internal fun parse(output: String): String? = output.trim().takeIf { it.toIntOrNull() != null }
|
||||
|
||||
private fun state(context: Context) = context.applicationContext.getSharedPreferences(STATE, Context.MODE_PRIVATE)
|
||||
|
||||
fun applied(context: Context): Boolean = state(context).getBoolean(APPLIED, false)
|
||||
|
||||
fun applyAsync(context: Context, limit: Int) {
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
runCatching {
|
||||
if (!applied(app)) {
|
||||
val previous = parse(WirelessAdbFix.shell(app, readCommand()))
|
||||
check(state(app).edit().putBoolean(APPLIED, true).putString(PREVIOUS, previous).commit()) { "previous value not saved" }
|
||||
}
|
||||
WirelessAdbFix.shell(app, setCommand(limit))
|
||||
}.onSuccess { Log.i(TAG, "at most $limit cached app(s) while the session runs") }
|
||||
.onFailure { Log.w(TAG, "limit not set: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreAsync(context: Context) {
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
if (!applied(app)) return@execute
|
||||
val previous = state(app).getString(PREVIOUS, null)
|
||||
runCatching { WirelessAdbFix.shell(app, restoreCommand(previous)) }
|
||||
.onSuccess {
|
||||
state(app).edit().putBoolean(APPLIED, false).remove(PREVIOUS).commit()
|
||||
Log.i(TAG, "cached-app limit put back (${previous ?: "Android's own"})")
|
||||
}
|
||||
.onFailure { Log.w(TAG, "limit not put back yet: ${it.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.gpu.SocNames
|
||||
|
||||
object CpuAllocationDefaults {
|
||||
data class Allocation(val client: String, val game: String)
|
||||
|
||||
val device: Allocation? by lazy {
|
||||
forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"),
|
||||
CpuCores.all.associateWith(CpuCores::maxGhz))
|
||||
}
|
||||
|
||||
fun forDevice(model: String, platform: String, frequencies: Map<Int, Double?>): Allocation? {
|
||||
val chip = model.trim().uppercase().substringBefore('-')
|
||||
val gen2 = chip in setOf("SM8550", "QCS8550") ||
|
||||
(chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true))
|
||||
if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } ||
|
||||
frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null
|
||||
val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList()
|
||||
if (clusters.map { it.size } != listOf(3, 4, 1)) return null
|
||||
return Allocation(
|
||||
client = CpuCores.format(clusters[2].map { it.key }.toSet()),
|
||||
game = CpuCores.format(clusters[1].map { it.key }.toSet()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -152,13 +152,17 @@ object DeviceReport {
|
||||
k("RAM total", FileUtils.sizeToString(mi.totalMem))
|
||||
k("RAM available", FileUtils.sizeToString(mi.availMem))
|
||||
}
|
||||
runCatching {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) k("Thermal status", thermalStatusName(pm.currentThermalStatus))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
k("Thermal headroom (10 s)", pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) })
|
||||
val pm = runCatching { context.getSystemService(Context.POWER_SERVICE) as? PowerManager }.getOrNull()
|
||||
if (pm != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
k("Thermal status", runCatching { thermalStatusName(pm.currentThermalStatus) }.getOrNull())
|
||||
}
|
||||
k("Power saver", pm.isPowerSaveMode)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
k("Thermal headroom (10 s)", runCatching {
|
||||
pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) }
|
||||
}.getOrNull())
|
||||
}
|
||||
k("Power saver", runCatching { pm.isPowerSaveMode }.getOrNull())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val rate = runCatching { context.getSystemService(PerformanceHintManager::class.java)?.preferredUpdateRateNanos }.getOrNull()
|
||||
@@ -200,7 +204,12 @@ object DeviceReport {
|
||||
h("Drivers")
|
||||
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
|
||||
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
|
||||
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
|
||||
if (gpu.model == 740) {
|
||||
val hint = com.droiddeck.launcher.gpu.TpUbwcHint
|
||||
k("TP UBWC hint", hint.forThisDevice(context) ?: "driver default")
|
||||
k("TP UBWC mode", hint.activeMode ?: SessionPrefs.TP_UBWC_AUTO)
|
||||
if (hint.restartRequired(context)) k("TP UBWC pending", "${SessionPrefs.tpUbwcMode(context)} (restart required)")
|
||||
}
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
|
||||
@@ -247,6 +256,9 @@ object DeviceReport {
|
||||
EsyncPacks.status(LinuxRuntime.rootDir(context)).let { k("droiddeck-esync packs", "${it.installed} installed, ${it.wanted} wanted") }
|
||||
k("Linux x86 (FEX)", if (com.droiddeck.launcher.runtime.LinuxFex.ready(context)) "ready" else "not set up")
|
||||
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
|
||||
k("zRAM: compress Steam during games", SessionPrefs.zramClient(context))
|
||||
k("RAM cap", SessionPrefs.ramCapMb(context).let { if (it > 0) "$it MB" else "off" })
|
||||
k("VRAM cap", SessionPrefs.vramCapMb(context).let { if (it > 0) "$it MB" else "driver default" })
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("proot fast path", SessionPrefs.prootFastPath(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
@@ -20,6 +22,7 @@ object DownloadSource {
|
||||
private const val KEY_MODE = "mode"
|
||||
private const val KEY_MIRROR = "mirror"
|
||||
private const val KEY_CUSTOM = "custom"
|
||||
private const val KEY_PROXY = "proxy"
|
||||
private const val KEY_FASTEST = "fastest"
|
||||
|
||||
private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md"
|
||||
@@ -175,6 +178,22 @@ object DownloadSource {
|
||||
prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply()
|
||||
}
|
||||
|
||||
fun proxyHostPort(context: Context): String = prefs(context)?.getString(KEY_PROXY, "") ?: ""
|
||||
|
||||
fun setProxy(context: Context, value: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply()
|
||||
}
|
||||
|
||||
/** An HTTP proxy every Downloader connection uses, or null when none is configured. */
|
||||
fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "")
|
||||
|
||||
internal fun proxyOf(value: String): Proxy? {
|
||||
val host = value.substringBefore(':')
|
||||
val port = value.substringAfter(':', "").toIntOrNull() ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
|
||||
}
|
||||
|
||||
fun reset(context: Context) {
|
||||
lastGood = null
|
||||
prefs(context)?.edit()?.clear()?.apply()
|
||||
@@ -205,4 +224,11 @@ object DownloadSource {
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("http://")) return ""
|
||||
return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed"
|
||||
}
|
||||
|
||||
internal fun normalizeProxy(value: String): String {
|
||||
val trimmed = value.trim().removePrefix("http://").trimEnd('/')
|
||||
val host = trimmed.substringBefore(':')
|
||||
val port = trimmed.substringAfter(':', "").toIntOrNull() ?: 0
|
||||
return if (host.isNotEmpty() && port in 1..65535) "$host:$port" else ""
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
@@ -277,7 +278,10 @@ public final class Downloader {
|
||||
}
|
||||
|
||||
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
// An explicit Proxy routes the connection through the app setting instead of the system-wide proxy.
|
||||
Proxy proxy = DownloadSource.INSTANCE.httpProxy();
|
||||
HttpURLConnection connection = (HttpURLConnection) (proxy != null
|
||||
? new URL(url).openConnection(proxy) : new URL(url).openConnection());
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(readTimeoutMs);
|
||||
connection.setInstanceFollowRedirects(true);
|
||||
|
||||
@@ -14,6 +14,8 @@ object GameEnvironmentOptions {
|
||||
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
|
||||
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_DXVK_D3D8", "1", R.string.game_env_dxvk_d3d8, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINE_D3D_CONFIG", "renderer=vulkan", R.string.game_env_wined3d_renderer, Type.CHOICE, listOf("renderer=vulkan", "renderer=gl")),
|
||||
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_NO_NTSYNC", "1", R.string.game_env_no_ntsync, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_NO_ESYNC", "1", R.string.game_env_no_esync, Type.TOGGLE, listOf("0", "1")),
|
||||
@@ -32,6 +34,7 @@ object GameEnvironmentOptions {
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("DROIDDECK_GAME_CORES", "", R.string.game_env_cores, Type.TEXT),
|
||||
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
|
||||
|
||||
@@ -8,7 +8,11 @@ object HostProcess {
|
||||
private const val TAG = "HostProcess"
|
||||
|
||||
@JvmStatic
|
||||
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?): Int {
|
||||
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?): Int =
|
||||
start(command, env, dir, onExit, onLine, null)
|
||||
|
||||
@JvmStatic
|
||||
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?, outputFile: File?): Int {
|
||||
val argv = split(command)
|
||||
if (argv.isEmpty()) return -1
|
||||
return try {
|
||||
@@ -19,7 +23,10 @@ object HostProcess {
|
||||
if (eq > 0) builder.environment()[line.substring(0, eq)] = line.substring(eq + 1)
|
||||
}
|
||||
builder.redirectErrorStream(true)
|
||||
if (onLine == null) builder.redirectOutput(File("/dev/null"))
|
||||
if (onLine == null) {
|
||||
if (outputFile == null) builder.redirectOutput(File("/dev/null"))
|
||||
else builder.redirectOutput(ProcessBuilder.Redirect.appendTo(outputFile))
|
||||
}
|
||||
val process = builder.start()
|
||||
val pid = pidOf(process)
|
||||
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
|
||||
|
||||
@@ -76,7 +76,7 @@ object LogRedactor {
|
||||
|
||||
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
|
||||
private val ACCOUNT_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|user[_-]?name|epicusername)" +
|
||||
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|account[_-]?id|user[_-]?name|epicusername)" +
|
||||
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
private val LOGIN_KV = Regex(
|
||||
|
||||
@@ -182,25 +182,47 @@ object WirelessAdbFix {
|
||||
}
|
||||
|
||||
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
|
||||
withSavedPairing(context) { appContext, host, port -> setChildProcessLimit(appContext, host, port, enabled) }
|
||||
}
|
||||
|
||||
fun hasSavedPairing(context: Context): Boolean = savedConnection(context.applicationContext) != null
|
||||
|
||||
fun clearCachedApps(context: Context) {
|
||||
shell(context, "am kill-all")
|
||||
}
|
||||
|
||||
fun shell(context: Context, command: String): String {
|
||||
var output = ""
|
||||
withSavedPairing(context) { appContext, host, port ->
|
||||
loadOrCreateIdentity(appContext)
|
||||
Kadb.create(host, port).use { adb ->
|
||||
val result = adb.shell(command)
|
||||
check(result.exitCode == 0) { result.allOutput.ifBlank { appContext.getString(R.string.adbfix_command_failed, result.exitCode) } }
|
||||
output = result.output
|
||||
}
|
||||
saveConnection(appContext, host, port)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private fun withSavedPairing(context: Context, action: (Context, String, Int) -> Unit) {
|
||||
val appContext = context.applicationContext
|
||||
val saved = savedConnection(appContext)
|
||||
?: error(appContext.getString(R.string.adbfix_no_pairing))
|
||||
val cachedPort = saved.port
|
||||
if (cachedPort != null) {
|
||||
val cachedResult = runCatching {
|
||||
setChildProcessLimit(appContext, saved.host, cachedPort, enabled)
|
||||
}
|
||||
val cachedResult = runCatching { action(appContext, saved.host, cachedPort) }
|
||||
if (cachedResult.isSuccess) return
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
if (discoveredPort != null && discoveredPort != cachedPort) {
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
action(appContext, saved.host, discoveredPort)
|
||||
} else {
|
||||
cachedResult.getOrThrow()
|
||||
}
|
||||
} else {
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
?: error(appContext.getString(R.string.adbfix_enter_address))
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
action(appContext, saved.host, discoveredPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.FrontEndActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -185,7 +185,7 @@ class WirelessAdbPairingService : Service() {
|
||||
enableVibration(false)
|
||||
})
|
||||
val open = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
Intent(this, FrontEndActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
val cancel = PendingIntent.getService(this, 1,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
|
||||
object ZramSupport {
|
||||
private const val GIB = 1L shl 30
|
||||
|
||||
fun supported(): Boolean = kernelAtLeast(System.getProperty("os.version").orEmpty(), 5, 4) && swapTotalKb() > 0
|
||||
|
||||
fun recommended(context: Context): Boolean {
|
||||
if (!supported()) return false
|
||||
val memory = ActivityManager.MemoryInfo()
|
||||
context.getSystemService(ActivityManager::class.java).getMemoryInfo(memory)
|
||||
val advertised = if (Build.VERSION.SDK_INT >= 34) memory.advertisedMem else 0L
|
||||
return belowTwelveGb(advertised, memory.totalMem)
|
||||
}
|
||||
|
||||
fun belowTwelveGb(advertisedBytes: Long, totalBytes: Long): Boolean =
|
||||
if (advertisedBytes > 0) advertisedBytes < 11 * GIB else totalBytes < 10 * GIB
|
||||
|
||||
fun kernelAtLeast(release: String, major: Int, minor: Int): Boolean {
|
||||
val parts = Regex("^(\\d+)\\.(\\d+)").find(release)?.groupValues ?: return false
|
||||
val ma = parts[1].toInt()
|
||||
|
||||
@@ -29,7 +29,7 @@ class FileManagerActivity : ComponentActivity() {
|
||||
setContent {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
FileManagerScreen(initialDir = initialDir, onExit = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardReturn
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
@@ -68,6 +70,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -90,12 +93,20 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
|
||||
private enum class FileManagerNavigation { EXIT, RETURN_TO_START, BACK, UP }
|
||||
|
||||
private const val HISTORY_LIMIT = 50
|
||||
|
||||
internal fun canNavigateUp(currentDir: File, currentRoot: File): Boolean =
|
||||
currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FileManagerScreen(
|
||||
@@ -105,6 +116,7 @@ fun FileManagerScreen(
|
||||
initialDir: File? = null,
|
||||
pickerTitle: String? = null,
|
||||
onPick: ((File) -> Unit)? = null,
|
||||
onExit: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -118,12 +130,14 @@ fun FileManagerScreen(
|
||||
|
||||
val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
|
||||
val browsePrefs = pickPrefs
|
||||
val rootDir = remember {
|
||||
val rootDir = remember(initialDir) {
|
||||
initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
|
||||
}
|
||||
|
||||
val launchDir = remember(initialDir, pickMode) {
|
||||
initialDir?.takeIf { !pickMode }
|
||||
}
|
||||
var currentDir by remember { mutableStateOf(rootDir) }
|
||||
var currentRoot by remember {
|
||||
val initialRoot = remember(rootDir) {
|
||||
val abs = rootDir.absolutePath
|
||||
val internal = "/storage/emulated/0"
|
||||
val vol = when {
|
||||
@@ -139,8 +153,9 @@ fun FileManagerScreen(
|
||||
?: rootDir
|
||||
}
|
||||
}
|
||||
mutableStateOf(vol)
|
||||
vol
|
||||
}
|
||||
var currentRoot by remember { mutableStateOf(initialRoot) }
|
||||
var entries by remember { mutableStateOf(listOf<File>()) }
|
||||
var selectedEntry by remember { mutableStateOf<File?>(null) }
|
||||
var showMenuFor by remember { mutableStateOf<File?>(null) }
|
||||
@@ -151,8 +166,10 @@ fun FileManagerScreen(
|
||||
var pendingConflict by remember { mutableStateOf<File?>(null) }
|
||||
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
|
||||
var conflictApplyToAll by remember { mutableStateOf(false) }
|
||||
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var operationJob by remember { mutableStateOf<Job?>(null) }
|
||||
var cancelledJob by remember { mutableStateOf<Job?>(null) }
|
||||
var pendingNavigation by remember { mutableStateOf<FileManagerNavigation?>(null) }
|
||||
var cancellingNavigation by remember { mutableStateOf(false) }
|
||||
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
val visibleEntries = if (searchQuery.isBlank()) entries
|
||||
@@ -178,10 +195,18 @@ fun FileManagerScreen(
|
||||
var showFavorites by remember { mutableStateOf(false) }
|
||||
var favTick by remember { mutableIntStateOf(0) }
|
||||
|
||||
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var listingJob by remember { mutableStateOf<Job?>(null) }
|
||||
val history = remember { mutableStateListOf<Pair<File, File>>() }
|
||||
var shownRoot by remember { mutableStateOf(initialRoot) }
|
||||
|
||||
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
|
||||
fun loadDirectory(dir: File, resetScroll: Boolean = true, record: Boolean = true) {
|
||||
if (cancellingNavigation) return
|
||||
if (record && dir != currentDir) {
|
||||
history.add(currentDir to shownRoot)
|
||||
if (history.size > HISTORY_LIMIT) history.removeAt(0)
|
||||
}
|
||||
currentDir = dir
|
||||
shownRoot = currentRoot
|
||||
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
|
||||
listingJob?.cancel()
|
||||
listingJob = scope.launch {
|
||||
@@ -205,25 +230,102 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun openDrive(dir: File) {
|
||||
if (cancellingNavigation) return
|
||||
currentRoot = dir
|
||||
loadDirectory(dir)
|
||||
}
|
||||
|
||||
fun returnToStart() {
|
||||
if (cancellingNavigation) return
|
||||
val dir = launchDir ?: return
|
||||
if (!dir.isDirectory) {
|
||||
Toast.makeText(context, R.string.fm_start_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
showFavorites = false
|
||||
showSearch = false
|
||||
searchQuery = ""
|
||||
currentRoot = initialRoot
|
||||
loadDirectory(dir)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { loadDirectory(rootDir) }
|
||||
|
||||
BackHandler(enabled = showFavorites || currentDir != currentRoot) {
|
||||
if (showFavorites) {
|
||||
showFavorites = false
|
||||
return@BackHandler
|
||||
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
fun isOperationActive() = isOperationRunning || operationJob?.isCompleted == false || cancelledJob?.isCompleted == false
|
||||
val operationActive = isOperationActive()
|
||||
|
||||
fun navigate(action: FileManagerNavigation) {
|
||||
if (cancellingNavigation) return
|
||||
when (action) {
|
||||
FileManagerNavigation.EXIT -> onExit?.invoke()
|
||||
FileManagerNavigation.RETURN_TO_START -> returnToStart()
|
||||
FileManagerNavigation.BACK -> {
|
||||
if (showFavorites) showFavorites = false
|
||||
else {
|
||||
while (history.isNotEmpty() && !history.last().first.isDirectory) history.removeAt(history.lastIndex)
|
||||
val previous = history.removeLastOrNull()
|
||||
if (previous != null) {
|
||||
currentRoot = previous.second
|
||||
loadDirectory(previous.first, record = false)
|
||||
} else if (canNavigateUp(currentDir, currentRoot)) currentDir.parentFile?.let { loadDirectory(it, record = false) }
|
||||
else onExit?.invoke()
|
||||
}
|
||||
}
|
||||
FileManagerNavigation.UP -> {
|
||||
if (showFavorites) showFavorites = false
|
||||
else if (canNavigateUp(currentDir, currentRoot)) currentDir.parentFile?.let { loadDirectory(it) }
|
||||
else onExit?.invoke()
|
||||
}
|
||||
}
|
||||
val parent = currentDir.parentFile
|
||||
if (parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
fun goUp() {
|
||||
val parent = currentDir.parentFile
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
fun requestNavigation(action: FileManagerNavigation) {
|
||||
if (cancellingNavigation) return
|
||||
if (isOperationActive()) pendingNavigation = action else navigate(action)
|
||||
}
|
||||
|
||||
BackHandler(enabled = operationActive || cancellingNavigation || showFavorites || currentDir != currentRoot || history.isNotEmpty()) {
|
||||
requestNavigation(FileManagerNavigation.BACK)
|
||||
}
|
||||
|
||||
if (pendingNavigation != null) {
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingNavigation = null },
|
||||
title = { Text(stringResource(R.string.fm_cancel_operation_title)) },
|
||||
text = { Text(stringResource(R.string.fm_cancel_operation_confirmation)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val action = pendingNavigation ?: return@TextButton
|
||||
pendingNavigation = null
|
||||
val job = operationJob?.takeUnless(Job::isCompleted)
|
||||
?: cancelledJob?.takeUnless(Job::isCompleted)
|
||||
if (job == null) {
|
||||
navigate(action)
|
||||
} else {
|
||||
cancellingNavigation = true
|
||||
cancelledJob = job
|
||||
job.cancel()
|
||||
scope.launch {
|
||||
job.join()
|
||||
if (operationJob === job) operationJob = null
|
||||
if (cancelledJob === job) cancelledJob = null
|
||||
pendingConflict = null
|
||||
isOperationRunning = false
|
||||
operationDeterminate = false
|
||||
cancellingNavigation = false
|
||||
if (action != FileManagerNavigation.EXIT) loadDirectory(currentDir, resetScroll = false)
|
||||
navigate(action)
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.fm_accept)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingNavigation = null }) {
|
||||
Text(stringResource(R.string.fm_decline))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun uniqueDestination(dir: File, name: String): File {
|
||||
@@ -241,11 +343,14 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun performDelete(file: File) {
|
||||
scope.launch {
|
||||
if (isOperationActive()) return
|
||||
operationJob = scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_deleting)
|
||||
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
|
||||
val job = coroutineContext[Job]
|
||||
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) { job?.isActive == false } }
|
||||
isOperationRunning = false
|
||||
if (operationJob === job) operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -260,7 +365,7 @@ fun FileManagerScreen(
|
||||
|
||||
fun performPaste() {
|
||||
val sources = clipboardFiles
|
||||
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
|
||||
if (sources.isEmpty() || isOperationActive()) return
|
||||
val dstDir = currentDir
|
||||
val cut = isCutOperation
|
||||
|
||||
@@ -269,7 +374,7 @@ fun FileManagerScreen(
|
||||
operationDeterminate = true
|
||||
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
||||
isOperationRunning = true
|
||||
val job = coroutineContext[kotlinx.coroutines.Job]
|
||||
val job = coroutineContext[Job]
|
||||
|
||||
var applyToAll: ConflictChoice? = null
|
||||
var failed = 0
|
||||
@@ -322,7 +427,7 @@ fun FileManagerScreen(
|
||||
|
||||
isOperationRunning = false
|
||||
operationDeterminate = false
|
||||
operationJob = null
|
||||
if (operationJob === job) operationJob = null
|
||||
clipboardFiles = emptyList()
|
||||
isCutOperation = false
|
||||
selectionMode = false
|
||||
@@ -340,32 +445,38 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun performRename(file: File, newName: String) {
|
||||
if (isOperationActive()) return
|
||||
val target = File(file.parentFile, newName)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
operationJob = scope.launch {
|
||||
val job = coroutineContext[Job]
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_renaming)
|
||||
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
|
||||
isOperationRunning = false
|
||||
if (operationJob === job) operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun createFolder(parent: File, name: String) {
|
||||
if (isOperationActive()) return
|
||||
val target = File(parent, name)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
operationJob = scope.launch {
|
||||
val job = coroutineContext[Job]
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_creating_folder)
|
||||
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
|
||||
isOperationRunning = false
|
||||
if (operationJob === job) operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -481,18 +592,20 @@ fun FileManagerScreen(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (isOperationActive()) return@TextButton
|
||||
pendingBulkDelete = emptyList()
|
||||
selectionMode = false
|
||||
selectedPaths = emptySet()
|
||||
operationJob = scope.launch {
|
||||
isOperationRunning = true
|
||||
val job = coroutineContext[Job]
|
||||
var failed = 0
|
||||
victims.forEachIndexed { i, f ->
|
||||
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
|
||||
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
|
||||
if (!withContext(Dispatchers.IO) { FileOps.delete(f) { job?.isActive == false } }) failed++
|
||||
}
|
||||
isOperationRunning = false
|
||||
operationJob = null
|
||||
if (operationJob === job) operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (failed > 0) {
|
||||
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
|
||||
@@ -582,7 +695,7 @@ fun FileManagerScreen(
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
IconButton(onClick = { goUp() }, enabled = canGoUp) {
|
||||
IconButton(onClick = { requestNavigation(FileManagerNavigation.UP) }, enabled = canGoUp && !cancellingNavigation) {
|
||||
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
@@ -672,6 +785,13 @@ fun FileManagerScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (launchDir != null) {
|
||||
IconButton(onClick = { requestNavigation(FileManagerNavigation.RETURN_TO_START) }, enabled = !cancellingNavigation) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardReturn, stringResource(R.string.fm_return_to_start), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (LocalConfiguration.current.screenWidthDp >= 700) Spacer(Modifier.width(56.dp))
|
||||
}
|
||||
|
||||
if (!showFavorites) {
|
||||
if (!pickMode) {
|
||||
OutlinedButton(
|
||||
@@ -747,6 +867,11 @@ fun FileManagerScreen(
|
||||
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
if (!pickMode && onExit != null) {
|
||||
IconButton(onClick = { requestNavigation(FileManagerNavigation.EXIT) }, enabled = !cancellingNavigation) {
|
||||
Icon(Icons.AutoMirrored.Filled.ExitToApp, stringResource(R.string.fm_exit), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSearch && !showFavorites) {
|
||||
@@ -986,7 +1111,7 @@ fun FileManagerScreen(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.UP) })
|
||||
}
|
||||
items(shownEntries, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
@@ -1052,7 +1177,7 @@ fun FileManagerScreen(
|
||||
} else
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
if (canGoUp) item(key = "..") {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.UP) })
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
object FileOps {
|
||||
private const val TAG = "FileOps"
|
||||
@@ -24,24 +25,27 @@ object FileOps {
|
||||
|
||||
fun isSymlink(file: File): Boolean = try { Files.isSymbolicLink(file.toPath()) } catch (e: Exception) { false }
|
||||
|
||||
fun delete(target: File?): Boolean {
|
||||
fun delete(target: File?, isCancelled: () -> Boolean = { false }): Boolean {
|
||||
if (isCancelled()) throw CancellationException("Delete cancelled: $target")
|
||||
if (target == null) return false
|
||||
if (target.isDirectory && !isSymlink(target)) {
|
||||
if (!clear(target)) return false
|
||||
if (!clear(target, isCancelled)) return false
|
||||
}
|
||||
if (isCancelled()) throw CancellationException("Delete cancelled: $target")
|
||||
return target.delete()
|
||||
}
|
||||
|
||||
fun clear(dir: File): Boolean {
|
||||
fun clear(dir: File, isCancelled: () -> Boolean = { false }): Boolean {
|
||||
val children = dir.listFiles() ?: return true
|
||||
var ok = true
|
||||
for (child in children) if (!delete(child)) ok = false
|
||||
for (child in children) if (!delete(child, isCancelled)) ok = false
|
||||
return ok
|
||||
}
|
||||
|
||||
fun totalSize(f: File?): Long {
|
||||
fun totalSize(f: File?, isCancelled: () -> Boolean = { false }): Long {
|
||||
if (isCancelled()) throw CancellationException("Size scan cancelled: $f")
|
||||
if (f == null || !f.exists() || isSymlink(f)) return 0
|
||||
if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it) } ?: 0
|
||||
if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it, isCancelled) } ?: 0
|
||||
return f.length()
|
||||
}
|
||||
|
||||
@@ -53,12 +57,12 @@ object FileOps {
|
||||
return true
|
||||
}
|
||||
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
|
||||
if (isCancelled()) return false
|
||||
// Once the destination is committed, finish deleting the source.
|
||||
return delete(src)
|
||||
}
|
||||
|
||||
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
|
||||
val total = maxOf(totalSize(src), 1L)
|
||||
val total = maxOf(totalSize(src, isCancelled), 1L)
|
||||
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
|
||||
}
|
||||
|
||||
@@ -71,15 +75,23 @@ object FileOps {
|
||||
return false
|
||||
}
|
||||
if (src.isDirectory) {
|
||||
if (!dst.exists() && !dst.mkdirs()) return false
|
||||
var allOk = true
|
||||
src.list()?.forEach { name ->
|
||||
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
|
||||
Log.e(TAG, "Failed to copy: ${File(src, name)}")
|
||||
allOk = false
|
||||
val created = if (dst.exists()) false else {
|
||||
if (!dst.mkdirs()) return false
|
||||
true
|
||||
}
|
||||
try {
|
||||
var allOk = true
|
||||
src.list()?.forEach { name ->
|
||||
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
|
||||
Log.e(TAG, "Failed to copy: ${File(src, name)}")
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
return allOk
|
||||
} catch (e: CancellationException) {
|
||||
if (created) delete(dst)
|
||||
throw e
|
||||
}
|
||||
return allOk
|
||||
}
|
||||
val parent = dst.parentFile
|
||||
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
|
||||
@@ -118,6 +130,7 @@ object FileOps {
|
||||
val size = input.size()
|
||||
var position = 0L
|
||||
while (position < size) {
|
||||
if (isCancelled()) throw CancellationException("Copy cancelled: $src")
|
||||
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
|
||||
if (transferred <= 0) break
|
||||
position += transferred
|
||||
@@ -125,12 +138,19 @@ object FileOps {
|
||||
progress?.onProgress(done[0], total)
|
||||
}
|
||||
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
|
||||
if (size > 0 && isCancelled()) throw CancellationException("Copy cancelled: $src")
|
||||
}
|
||||
}
|
||||
staging?.let { directory ->
|
||||
val copied = File(directory, "file").toPath()
|
||||
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
|
||||
Files.setPosixFilePermissions(copied, permissions)
|
||||
if (permissions != null) {
|
||||
try {
|
||||
if (Files.getPosixFilePermissions(copied) != permissions) {
|
||||
Files.setPosixFilePermissions(copied, permissions)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to restore destination permissions: $target", e)
|
||||
}
|
||||
}
|
||||
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
|
||||
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
|
||||
|
||||
@@ -50,6 +50,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
|
||||
finish()
|
||||
},
|
||||
onExit = { finish() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,50 @@ object StorageRoots {
|
||||
val seeds = LinkedHashSet<File>()
|
||||
}
|
||||
|
||||
private val EXTERNAL_MOUNT_TYPES = setOf(
|
||||
"vfat", "exfat", "texfat", "ntfs", "ntfs3", "fuseblk", "fuse", "sdcardfs",
|
||||
)
|
||||
|
||||
internal data class MountPoint(val target: File, val type: String)
|
||||
|
||||
internal fun parseMountPoints(mounts: String): List<MountPoint> {
|
||||
val out = ArrayList<MountPoint>()
|
||||
for (line in mounts.lineSequence()) {
|
||||
val parts = line.split(' ')
|
||||
if (parts.size < 3) continue
|
||||
out.add(MountPoint(File(parts[1].replace("\\040", " ")), parts[2].lowercase()))
|
||||
}
|
||||
return out.distinct()
|
||||
}
|
||||
|
||||
internal fun isExternalMount(mount: MountPoint): Boolean {
|
||||
val path = mount.target.path
|
||||
val name = when {
|
||||
path.startsWith("/storage/") -> path.removePrefix("/storage/")
|
||||
path.startsWith("/mnt/media_rw/") -> path.removePrefix("/mnt/media_rw/")
|
||||
path.startsWith("/mnt/") -> path.removePrefix("/mnt/")
|
||||
else -> return false
|
||||
}
|
||||
if ('/' in name || name.isEmpty() || name == "emulated" || name == "self") return false
|
||||
if (mount.type in EXTERNAL_MOUNT_TYPES) {
|
||||
if (path.startsWith("/mnt/") && !path.startsWith("/mnt/media_rw/")) {
|
||||
val lower = name.lowercase()
|
||||
if (!lower.contains("usb") && !lower.contains("otg") &&
|
||||
!lower.contains("udisk") && !lower.contains("disk")
|
||||
) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// vold-managed locations only hold removable volumes, whatever the fstype is.
|
||||
return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/")
|
||||
}
|
||||
|
||||
internal fun parseExternalMounts(mounts: String): List<File> =
|
||||
parseMountPoints(mounts).filter(::isExternalMount).map { it.target }
|
||||
|
||||
fun externalMounts(): List<File> =
|
||||
runCatching { parseExternalMounts(File("/proc/mounts").readText()) }.getOrDefault(emptyList())
|
||||
|
||||
fun list(context: Context): List<StorageRoot> {
|
||||
val volumes = LinkedHashMap<String, Volume>()
|
||||
|
||||
@@ -44,21 +88,31 @@ object StorageRoots {
|
||||
val storageManager = context.getSystemService(StorageManager::class.java)
|
||||
|
||||
storageManager?.storageVolumes.orEmpty()
|
||||
.filter { isMounted(it.state) }
|
||||
.forEach { volume ->
|
||||
.filter { present(it.state) }
|
||||
.forEachIndexed { index, volume ->
|
||||
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
|
||||
val entry = volumeOf(if (volume.isPrimary) PRIMARY_KEY else uuid ?: return@forEach)
|
||||
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
|
||||
// Unmounted volumes often report neither uuid nor directory; never drop the
|
||||
// volume or a plugged-in drive stays invisible with no explanation.
|
||||
val descSlug = volume.getDescription(context)?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
|
||||
val entry = volumeOf(if (volume.isPrimary) PRIMARY_KEY else uuid ?: dirName ?: descSlug ?: "volume-$index")
|
||||
entry.removable = !volume.isPrimary && volume.isRemovable
|
||||
if (entry.label == null) entry.label = labelFor(context, volume, uuid)
|
||||
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
volume.directory?.let { entry.candidates += it }
|
||||
}
|
||||
if (uuid != null) {
|
||||
entry.candidates += File("/storage/$uuid")
|
||||
entry.candidates += File("/mnt/media_rw/$uuid")
|
||||
val name = uuid ?: dirName
|
||||
if (name != null) {
|
||||
entry.candidates += File("/storage/$name")
|
||||
entry.candidates += File("/mnt/media_rw/$name")
|
||||
// Only strict uuid matches own an app directory; a loose match would
|
||||
// attach another volume's folder to this entry.
|
||||
if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) }
|
||||
.forEach { entry.seeds += it }
|
||||
}
|
||||
appDirs.filter { belongsTo(storageManager!!, it, volume) }.forEach { entry.seeds += it }
|
||||
}
|
||||
|
||||
File("/storage").listFiles().orEmpty().forEach { child ->
|
||||
@@ -68,6 +122,13 @@ object StorageRoots {
|
||||
File("/mnt/media_rw").listFiles().orEmpty().forEach { child ->
|
||||
if (child.isDirectory) volumeOf(child.name).candidates += child
|
||||
}
|
||||
// USB OTG drives do not always appear as storage volumes or under /storage for
|
||||
// third-party apps, but their kernel mounts are still visible in /proc/mounts.
|
||||
for (mount in externalMounts()) {
|
||||
val entry = volumeOf(mount.name)
|
||||
if (entry.label == null) entry.label = mount.name
|
||||
entry.candidates += mount
|
||||
}
|
||||
|
||||
appDirs.forEach { dir ->
|
||||
val root = volumeRootOf(dir) ?: return@forEach
|
||||
@@ -141,15 +202,19 @@ object StorageRoots {
|
||||
|
||||
private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String {
|
||||
if (volume.isPrimary) return context.getString(R.string.fm_drive_internal)
|
||||
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
|
||||
val description = volume.getDescription(context)?.trim()
|
||||
return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?: uuid
|
||||
if (!description.isNullOrBlank() && !description.equals("android", ignoreCase = true)) return description
|
||||
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
|
||||
return uuid
|
||||
?: context.getString(R.string.fm_drive_storage)
|
||||
}
|
||||
|
||||
private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX)
|
||||
|
||||
private fun present(state: String?): Boolean =
|
||||
isMounted(state) || state == Environment.MEDIA_UNMOUNTED || state == Environment.MEDIA_CHECKING ||
|
||||
state == Environment.MEDIA_NOFS || state == Environment.MEDIA_UNMOUNTABLE
|
||||
|
||||
private fun isMounted(state: String?): Boolean =
|
||||
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.files.PeIconExtractor
|
||||
import com.droiddeck.launcher.runtime.BattleNet
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreArt
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
@@ -73,14 +77,18 @@ object AddedGameArt {
|
||||
)
|
||||
}
|
||||
|
||||
private fun storeArt(context: Context, game: AddedGames.Game): BattleNet.Art? =
|
||||
if (game.source != Store.BATTLENET.id) null else game.storeId?.let(BattleNet::title)?.let { BattleNet.art(context, it) }
|
||||
|
||||
fun resolve(context: Context, game: AddedGames.Game): Art {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
val store = storeArt(context, game)
|
||||
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
|
||||
return Art(
|
||||
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
|
||||
header = chosen(cache, Slot.WIDE) ?: own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
|
||||
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: store?.cover ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
|
||||
header = chosen(cache, Slot.WIDE) ?: own.header ?: store?.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: store?.header ?: 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),
|
||||
)
|
||||
@@ -89,15 +97,16 @@ object AddedGameArt {
|
||||
fun describe(context: Context, game: AddedGames.Game): List<SlotState> {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
val store = storeArt(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.WIDE -> own.header?.let { it to FOLDER } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
|
||||
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
|
||||
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: store?.cover?.let { it to AUTO } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
|
||||
Slot.WIDE -> own.header?.let { it to FOLDER } ?: store?.header?.let { it to AUTO } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
|
||||
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: store?.header?.let { it to AUTO } ?: 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 }
|
||||
@@ -228,46 +237,103 @@ object AddedGameArt {
|
||||
return if (tmp.renameTo(png)) png else null
|
||||
}
|
||||
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
|
||||
internal class Sources(
|
||||
val storeLogo: (AddedGames.Game) -> Boolean,
|
||||
val steamSearch: (String) -> Int?,
|
||||
val steam: (String, File, Set<Slot>) -> Boolean,
|
||||
val sgdbGame: (String, Int?, String) -> Int?,
|
||||
val sgdb: (String, Int, File, Set<Slot>) -> Boolean,
|
||||
)
|
||||
|
||||
private val network = Sources(
|
||||
storeLogo = ::storeLogo,
|
||||
steamSearch = { name -> search(name)?.toIntOrNull() },
|
||||
steam = ::download,
|
||||
sgdbGame = SteamGridDb::gameId,
|
||||
sgdb = SteamGridDb::download,
|
||||
)
|
||||
|
||||
private val EXTRAS = setOf(Slot.LOGO, Slot.ICON)
|
||||
private val STEAM_SLOTS = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO)
|
||||
internal const val EXTRAS_TRIED = "extras-tried"
|
||||
|
||||
internal fun missing(context: Context, game: AddedGames.Game): Set<Slot> {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
val store = storeArt(context, game)
|
||||
fun has(slot: Slot, mine: File?, cached: String) = chosen(cache, slot) != null || mine != null || File(cache, cached).isFile
|
||||
return Slot.values().filterNot { slot ->
|
||||
when (slot) {
|
||||
Slot.COVER -> has(slot, own.portrait ?: store?.cover, "p.jpg")
|
||||
Slot.WIDE -> has(slot, own.header ?: store?.header, "header.jpg")
|
||||
Slot.BACKGROUND -> has(slot, own.hero ?: store?.header, "hero.jpg")
|
||||
Slot.LOGO -> has(slot, own.logo, "logo.png")
|
||||
Slot.ICON -> has(slot, own.icon, "icon-sgdb.png")
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private fun storeLogo(game: AddedGames.Game): Boolean {
|
||||
val url = StoreGameSidecar.read(game.folder)?.logo ?: return false
|
||||
return StoreArt.fetchLogo(game.folder, url)
|
||||
}
|
||||
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean =
|
||||
fetchMissing(context, games, SteamGridDb.key(context), network)
|
||||
|
||||
internal fun fetchMissing(context: Context, games: List<AddedGames.Game>, sgdbKey: String, sources: Sources): Boolean {
|
||||
if (!SessionPrefs.addedGamesArt(context)) return false
|
||||
val sgdbKey = SteamGridDb.key(context)
|
||||
val keyChangedAt = SteamGridDb.keyChangedAt(context)
|
||||
var sgdbDown = false
|
||||
var changed = false
|
||||
for (game in games) {
|
||||
if (local(game).portrait != null) continue
|
||||
val start = missing(context, game)
|
||||
val full = Slot.COVER in start
|
||||
val allowed = if (full) Slot.values().toSet() else EXTRAS
|
||||
var need = start intersect allowed
|
||||
if (need.isEmpty() || (need == setOf(Slot.ICON) && sgdbKey.isEmpty())) continue
|
||||
val cache = cacheDir(context, game)
|
||||
if (File(cache, "p.jpg").isFile) continue
|
||||
val tried = File(cache, EXTRAS_TRIED)
|
||||
if (!full && tried.isFile && tried.lastModified() >= keyChangedAt && System.currentTimeMillis() - tried.lastModified() < RETRY_AFTER_MS) continue
|
||||
cache.mkdirs()
|
||||
val match = steamMatch(context, game) { name ->
|
||||
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
|
||||
if (Slot.LOGO in need && game.source != Library.ADDED && runCatching { sources.storeLogo(game) }.getOrDefault(false)) {
|
||||
changed = true
|
||||
need = need - Slot.LOGO
|
||||
}
|
||||
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() || sgdbDown) continue
|
||||
val stored: String? = try {
|
||||
remembered(File(cache, "sgdb-id"), keyChangedAt) {
|
||||
SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString()
|
||||
val match by lazy {
|
||||
steamMatch(context, game) { name ->
|
||||
runCatching { sources.steamSearch(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass")
|
||||
sgdbDown = true
|
||||
null
|
||||
}
|
||||
val gridId = stored?.toIntOrNull() ?: continue
|
||||
val grabbed = try {
|
||||
SteamGridDb.download(sgdbKey, gridId, cache)
|
||||
} catch (e: Exception) {
|
||||
sgdbDown = true
|
||||
false
|
||||
val steamWant = need intersect STEAM_SLOTS
|
||||
if (steamWant.isNotEmpty()) {
|
||||
val id = match.appId?.toString()
|
||||
if (id != null && sources.steam(id, cache, steamWant)) changed = true
|
||||
need = missing(context, game) intersect allowed
|
||||
}
|
||||
if (grabbed) changed = true
|
||||
var asked = true
|
||||
if (need.isNotEmpty() && sgdbKey.isNotEmpty()) {
|
||||
if (sgdbDown) asked = false else try {
|
||||
val gridId = remembered(File(cache, "sgdb-id"), keyChangedAt) {
|
||||
sources.sgdbGame(sgdbKey, match.appId, game.name)?.toString()
|
||||
}?.toIntOrNull()
|
||||
if (gridId != null && sources.sgdb(sgdbKey, gridId, cache, need)) changed = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${game.name}: SteamGridDB failed (${e.javaClass.simpleName}); not asking again this pass")
|
||||
sgdbDown = true
|
||||
asked = false
|
||||
}
|
||||
}
|
||||
if ((missing(context, game) intersect EXTRAS).isEmpty()) tried.delete() else if (asked) tried.writeText("")
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
|
||||
if (game.source == Store.BATTLENET.id) {
|
||||
val id = game.storeId?.let(BattleNet::title)?.steam
|
||||
return SteamMatch.Match(id, if (id == null) SteamMatch.Certainty.NONE else SteamMatch.Certainty.FILES, Store.BATTLENET.id, System.currentTimeMillis())
|
||||
}
|
||||
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
|
||||
@@ -307,18 +373,20 @@ object AddedGameArt {
|
||||
.filter { it.second > 0 && it.first.isNotEmpty() }
|
||||
.maxByOrNull { it.second }?.first
|
||||
|
||||
private fun search(name: String): String? {
|
||||
internal fun search(name: String): String? {
|
||||
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
|
||||
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 {
|
||||
internal fun download(id: String, cache: File, slots: Set<Slot> = Slot.entries.toSet()): Boolean {
|
||||
var got = false
|
||||
for ((remote, localName) in listOf(
|
||||
"library_600x900.jpg" to "p.jpg", "header.jpg" to "header.jpg", "library_hero.jpg" to "hero.jpg", "logo.png" to "logo.png",
|
||||
for ((slot, remote, localName) in listOf(
|
||||
Triple(Slot.COVER, "library_600x900.jpg", "p.jpg"), Triple(Slot.WIDE, "header.jpg", "header.jpg"),
|
||||
Triple(Slot.BACKGROUND, "library_hero.jpg", "hero.jpg"), Triple(Slot.LOGO, "logo.png", "logo.png"),
|
||||
)) {
|
||||
if (slot !in slots) continue
|
||||
val dst = File(cache, localName)
|
||||
if (dst.isFile) continue
|
||||
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
|
||||
|
||||
@@ -79,6 +79,18 @@ object AddedGames {
|
||||
return null
|
||||
}
|
||||
|
||||
fun hostPath(context: Context, guest: String): File? {
|
||||
if (!guest.startsWith("/") || guest.split('/').any { it == ".." }) return null
|
||||
val mapped = roots(context).map { it.host to it.guest } + StoreInstallRoot.externalRoots(context) +
|
||||
listOfNotNull(GameStorage.effective(context)?.let { File(it.path) to LIBRARY }) +
|
||||
(android.os.Environment.getExternalStorageDirectory() to "/root/Storage")
|
||||
for ((host, root) in mapped.sortedByDescending { it.second.length }) {
|
||||
if (guest == root) return host
|
||||
if (guest.startsWith("$root/")) return File(host, guest.removePrefix("$root/"))
|
||||
}
|
||||
return File(LinuxRuntime.rootDir(context), guest.removePrefix("/"))
|
||||
}
|
||||
|
||||
fun candidates(folder: File): List<File> = rank(folder).exes
|
||||
|
||||
fun rank(folder: File): GameExePicker.Ranking {
|
||||
@@ -177,6 +189,9 @@ object AddedGames {
|
||||
scanGame(context, folder, out, single = entry)
|
||||
}
|
||||
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
|
||||
for (game in com.droiddeck.launcher.runtime.BattleNet.games(context)) {
|
||||
if (game.folder.path !in removed) com.droiddeck.launcher.runtime.BattleNet.shortcut(context, game)?.let(out::add)
|
||||
}
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
@@ -274,7 +289,7 @@ object AddedGames {
|
||||
|
||||
class FolderResult(val added: List<Game>, val already: Int)
|
||||
|
||||
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
|
||||
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult {
|
||||
val app = context.applicationContext
|
||||
// Binds are mounted at session start, so a new one reaches the session from the next start.
|
||||
if (guestPath(app, dir) == null) bindFolder(app, dir)
|
||||
@@ -283,7 +298,7 @@ object AddedGames {
|
||||
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()) {
|
||||
for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !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)
|
||||
@@ -387,11 +402,17 @@ object AddedGames {
|
||||
}
|
||||
}
|
||||
|
||||
private class LoginUser(val id: String, val mostRecent: Boolean, val autoLogin: Boolean, val timestamp: Long)
|
||||
|
||||
internal fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users).map {
|
||||
val body = it.groupValues[2]
|
||||
LoginUser(it.groupValues[1], Regex(""""MostRecent"\s*"1"""").containsMatchIn(body), Regex(""""AutoLogin"\s*"1"""").containsMatchIn(body),
|
||||
Regex(""""timestamp"\s*"(\d+)"""").find(body)?.groupValues?.get(1)?.toLongOrNull() ?: 0L)
|
||||
}.filter { it.mostRecent || it.autoLogin || it.timestamp > 0 }.toList().asReversed()
|
||||
.maxWithOrNull(compareBy<LoginUser>({ it.mostRecent }, { it.autoLogin }, { it.timestamp }))
|
||||
val account = recent?.id?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
|
||||
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
|
||||
return account?.let { File(root, "userdata/$it/config") }
|
||||
}
|
||||
|
||||
@@ -257,14 +257,18 @@ object AutoComponents {
|
||||
|
||||
|
||||
private const val FINDINGS = "wincomp-findings.json"
|
||||
private const val DETECTORS = "d3"
|
||||
|
||||
internal fun signature(context: Context, game: Library.SteamGame): String {
|
||||
val folder = game.gameFiles
|
||||
val parts = listOfNotNull(
|
||||
DETECTORS,
|
||||
folder?.lastModified(),
|
||||
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
|
||||
folder?.let { StoreGameSidecar.file(it).lastModified() },
|
||||
folder?.let { File(it, "fuel.json").lastModified() },
|
||||
folder?.listFiles { f -> f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") } }
|
||||
?.maxOfOrNull { it.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) },
|
||||
|
||||
@@ -11,7 +11,7 @@ object GameRemoval {
|
||||
enum class Kind { STORE, SHORTCUT, LAUNCHER }
|
||||
|
||||
fun kind(game: Library.SteamGame): Kind = when {
|
||||
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
|
||||
game.library == Library.EPIC_LAUNCHER || game.library == Library.BATTLENET_LAUNCHER -> Kind.LAUNCHER
|
||||
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
|
||||
else -> Kind.STORE
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.LauncherIcon
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.FrontEndActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
object GameShortcuts {
|
||||
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
|
||||
Uri.parse(GameLaunchLink.uri(gameId)), context, MainActivity::class.java)
|
||||
Uri.parse(GameLaunchLink.uri(gameId)), context, FrontEndActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
|
||||
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
|
||||
|
||||
@@ -16,16 +16,23 @@ object Library {
|
||||
val icon: File? = null,
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
val logo: File? = null,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId)
|
||||
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
||||
val profileKey: String get() = when {
|
||||
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
|
||||
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
|
||||
else -> appId.toString()
|
||||
}
|
||||
}
|
||||
const val ADDED = "added"
|
||||
const val EPIC_LAUNCHER = "epic-launcher"
|
||||
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||
const val BATTLENET_LAUNCHER = "battlenet-launcher"
|
||||
const val XBOX_CLOUD = "xbox-cloud"
|
||||
const val SOURCE_STEAM = "steam"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
@@ -45,12 +52,14 @@ object Library {
|
||||
1054830,
|
||||
1070560,
|
||||
1113280,
|
||||
1161040,
|
||||
1245040,
|
||||
1391110,
|
||||
1420170,
|
||||
1493710,
|
||||
1580130,
|
||||
1628350,
|
||||
1826330,
|
||||
1887720,
|
||||
2180100,
|
||||
228980,
|
||||
@@ -68,6 +77,7 @@ object Library {
|
||||
)
|
||||
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
|
||||
private val STEAM_HEROES = listOf("library_hero.jpg")
|
||||
internal val STEAM_LOGOS = listOf("logo.png")
|
||||
private val STEAM_ICON = Regex("[a-fA-F0-9]{40}\\.(jpg|png)")
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
@@ -102,7 +112,10 @@ object Library {
|
||||
.firstOrNull { it.isDirectory }
|
||||
}
|
||||
|
||||
fun steamGames(context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context)): List<SteamGame> {
|
||||
fun steamGames(
|
||||
context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context),
|
||||
withShared: Boolean = true,
|
||||
): List<SteamGame> {
|
||||
val root = SessionPrefs.steamRoot(context, arch)
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = steamLibraries(context, arch)
|
||||
@@ -119,8 +132,9 @@ object Library {
|
||||
val name = NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
if (name.isEmpty() || flags and 4 == 0) return@forEach
|
||||
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
|
||||
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
|
||||
val images = SteamCacheImages(File(cache, appId.toString()))
|
||||
val art = images.find(STEAM_CAPSULES)
|
||||
val hero = images.find(STEAM_HEROES)
|
||||
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
|
||||
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
|
||||
@@ -129,14 +143,25 @@ object Library {
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
icon = images.icon(),
|
||||
logo = images.find(STEAM_LOGOS),
|
||||
))
|
||||
}
|
||||
}
|
||||
if (withShared) {
|
||||
val other = if (arch == "x86_64") "arm64" else "x86_64"
|
||||
val shared = sharedApps(context, arch) - out.keys
|
||||
if (shared.isNotEmpty()) runCatching { steamGames(context, arch = other, withShared = false) }.getOrDefault(emptyList())
|
||||
.filter { it.appId in shared }.forEach { out[it.appId] = it }
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
internal fun sharedApps(context: Context, arch: String): Set<Int> {
|
||||
val view = File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/shared/${if (arch == "x86_64") "arm64" else "x86-64"}/steamapps")
|
||||
return view.list().orEmpty().mapNotNull { Regex("appmanifest_(\\d+)\\.acf").matchEntire(it)?.groupValues?.get(1)?.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
|
||||
val root = SessionPrefs.activeSteamRoot(context)
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
@@ -184,9 +209,24 @@ object Library {
|
||||
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
|
||||
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
|
||||
}
|
||||
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
val played = shortcutsLastPlayed(SessionPrefs.steamRoot(context, arch))
|
||||
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
/** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */
|
||||
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptyMap()
|
||||
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
|
||||
val vdf = SteamAppInfo.readMap(java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN), null)
|
||||
(vdf["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()?.mapNotNull { s ->
|
||||
fun int(key: String) = s.entries.firstOrNull { (it.key as? String).equals(key, ignoreCase = true) }
|
||||
?.let { (it.value as? String)?.toLongOrNull() }?.and(0xFFFFFFFFL)
|
||||
val id = int("appid") ?: return@mapNotNull null
|
||||
val time = int("LastPlayTime")?.takeIf { it > 0L } ?: return@mapNotNull null
|
||||
id to time
|
||||
}?.toMap()
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
|
||||
val dir = g.gameFiles ?: return@flatMap emptyList()
|
||||
listOfNotNull(
|
||||
@@ -195,24 +235,35 @@ object Library {
|
||||
)
|
||||
}.toSet()
|
||||
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
|
||||
fun addedGame(
|
||||
context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first },
|
||||
lastPlayed: Long = 0L,
|
||||
): 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),
|
||||
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
|
||||
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
|
||||
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
logo = art.logo,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
val dirs = listOf(appDir) + appDir.listFiles()
|
||||
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
|
||||
return names.asSequence()
|
||||
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? =
|
||||
SteamCacheImages(File(cache, appId.toString())).find(names)
|
||||
|
||||
internal class SteamCacheImages(appDir: File) {
|
||||
private val entries = appDir.listFiles().orEmpty()
|
||||
private val dirs = listOf(appDir) + entries.filter { it.isDirectory }.sortedBy { it.name }
|
||||
|
||||
fun find(names: List<String>): File? = names.asSequence()
|
||||
.flatMap { name -> dirs.asSequence().map { File(it, name) } }
|
||||
.firstOrNull { it.isFile && it.length() > 0L }
|
||||
|
||||
fun icon(): File? = find(listOf("icon.jpg", "icon.png"))
|
||||
?: entries.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) }
|
||||
}
|
||||
|
||||
private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List<String>, val exts: Set<String>, val atPanel: Boolean = false)
|
||||
|
||||
@@ -27,6 +27,7 @@ object LibraryCache {
|
||||
icon = o.file("icon"),
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
logo = o.file("logo"),
|
||||
)
|
||||
} to x64
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
|
||||
@@ -44,7 +45,7 @@ object LibraryCache {
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
|
||||
@@ -22,10 +22,12 @@ object SteamGameArt {
|
||||
val cover = chosenFile(context, game.appId, AddedGameArt.Slot.COVER)
|
||||
val hero = chosenFile(context, game.appId, AddedGameArt.Slot.BACKGROUND)
|
||||
val icon = chosenFile(context, game.appId, AddedGameArt.Slot.ICON)
|
||||
if (cover == null && hero == null && icon == null) return game
|
||||
val logo = chosenFile(context, game.appId, AddedGameArt.Slot.LOGO)
|
||||
if (cover == null && hero == null && icon == null && logo == null) return game
|
||||
return Library.SteamGame(
|
||||
game.appId, game.name, cover ?: game.art, game.library, game.gameId,
|
||||
hero ?: game.hero, game.lastPlayed, game.gameFiles, game.protonPrefix, icon ?: game.icon, game.source, game.storeId,
|
||||
logo ?: game.logo,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
|
||||
object SteamGrid {
|
||||
@@ -41,14 +42,42 @@ object SteamGrid {
|
||||
|
||||
private fun sigFile(context: Context, target: Target, slot: AddedGameArt.Slot) = File(target.artDir, sigName(context, slot.key))
|
||||
|
||||
private fun recorded(context: Context, target: Target, slot: AddedGameArt.Slot): String? =
|
||||
sigFile(context, target, slot).takeIf { it.isFile }?.readText()
|
||||
private const val GRID_ID = ".droiddeck-grid"
|
||||
private const val UNKNOWN_GRID = "?"
|
||||
|
||||
private fun record(context: Context, target: Target, slot: AddedGameArt.Slot, value: String) {
|
||||
target.artDir.mkdirs()
|
||||
sigFile(context, target, slot).writeText(value)
|
||||
/**
|
||||
* A random id kept in the grid folder, so a record made against another account's folder, or one wiped with the
|
||||
* runtime, reads as "not seen yet" instead of "removed in Steam". "?" never matches, so nothing is dropped.
|
||||
*/
|
||||
private fun gridId(grid: File): String = runCatching {
|
||||
val f = File(grid, GRID_ID)
|
||||
f.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also { id ->
|
||||
grid.mkdirs()
|
||||
val tmp = File(grid, "$GRID_ID.tmp")
|
||||
tmp.writeText(id)
|
||||
if (f.isFile) f.delete()
|
||||
if (!tmp.renameTo(f)) error("grid id")
|
||||
}
|
||||
}.getOrDefault(UNKNOWN_GRID)
|
||||
|
||||
private fun readRecord(grid: File, file: File): String? {
|
||||
val text = file.takeIf { it.isFile }?.readText() ?: return null
|
||||
val cut = text.indexOf('\n')
|
||||
val id = gridId(grid)
|
||||
return if (cut > 0 && id != UNKNOWN_GRID && text.substring(0, cut) == id) text.substring(cut + 1) else null
|
||||
}
|
||||
|
||||
private fun writeRecord(grid: File, target: Target, file: File, value: String) {
|
||||
target.artDir.mkdirs()
|
||||
file.writeText("${gridId(grid)}\n$value")
|
||||
}
|
||||
|
||||
private fun recorded(context: Context, grid: File, target: Target, slot: AddedGameArt.Slot): String? =
|
||||
readRecord(grid, sigFile(context, target, slot))
|
||||
|
||||
private fun record(context: Context, grid: File, target: Target, slot: AddedGameArt.Slot, value: String) =
|
||||
writeRecord(grid, target, sigFile(context, target, slot), value)
|
||||
|
||||
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
|
||||
val manifest = JSONObject(File(grid, MANIFEST).readText())
|
||||
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
|
||||
@@ -62,7 +91,7 @@ object SteamGrid {
|
||||
val grid = dir(context) ?: return true
|
||||
if (file == null) {
|
||||
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
|
||||
record(context, target, slot, "")
|
||||
record(context, grid, target, slot, "")
|
||||
return true
|
||||
}
|
||||
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
|
||||
@@ -76,7 +105,7 @@ object SteamGrid {
|
||||
return false
|
||||
}
|
||||
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
|
||||
record(context, target, slot, signature(placed))
|
||||
record(context, grid, target, slot, signature(placed))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -90,13 +119,11 @@ object SteamGrid {
|
||||
/** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
|
||||
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
|
||||
|
||||
private fun positionRecorded(context: Context, target: Target): String? =
|
||||
File(target.artDir, sigName(context, POSITION_SIG)).takeIf { it.isFile }?.readText()
|
||||
private fun positionRecorded(context: Context, grid: File, target: Target): String? =
|
||||
readRecord(grid, File(target.artDir, sigName(context, POSITION_SIG)))
|
||||
|
||||
private fun recordPosition(context: Context, target: Target, value: String) {
|
||||
target.artDir.mkdirs()
|
||||
File(target.artDir, sigName(context, POSITION_SIG)).writeText(value)
|
||||
}
|
||||
private fun recordPosition(context: Context, grid: File, target: Target, value: String) =
|
||||
writeRecord(grid, target, File(target.artDir, sigName(context, POSITION_SIG)), value)
|
||||
|
||||
private fun storePosition(target: Target, value: LogoPosition.Value?): Boolean {
|
||||
val file = File(target.artDir, POSITION)
|
||||
@@ -119,7 +146,7 @@ object SteamGrid {
|
||||
val next = if (value == null) existing?.let(LogoPosition::without) else LogoPosition.toJson(value, existing ?: JSONObject())
|
||||
if (next == null) {
|
||||
if (file.isFile && !file.delete()) return false
|
||||
recordPosition(context, target, "")
|
||||
recordPosition(context, grid, target, "")
|
||||
return true
|
||||
}
|
||||
grid.mkdirs()
|
||||
@@ -129,7 +156,7 @@ object SteamGrid {
|
||||
tmp.delete()
|
||||
return false
|
||||
}
|
||||
recordPosition(context, target, signature(file))
|
||||
recordPosition(context, grid, target, signature(file))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -144,16 +171,16 @@ object SteamGrid {
|
||||
if (hasUnknownPosition(file)) return
|
||||
val theirs = positionIn(file)
|
||||
val now = if (theirs != null) signature(file) else ""
|
||||
val before = positionRecorded(context, target)
|
||||
val before = positionRecorded(context, grid, target)
|
||||
val own = position(target)
|
||||
when {
|
||||
before == null && own != null && theirs == null -> placePosition(context, target, own)
|
||||
before == null || now != before -> {
|
||||
// Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
|
||||
if (theirs != null) {
|
||||
if (own == theirs || storePosition(target, theirs)) recordPosition(context, target, now)
|
||||
if (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now)
|
||||
} else {
|
||||
if (own == null || storePosition(target, null)) recordPosition(context, target, "")
|
||||
if (own == null || storePosition(target, null)) recordPosition(context, grid, target, "")
|
||||
}
|
||||
}
|
||||
own != null && own != theirs -> placePosition(context, target, own)
|
||||
@@ -173,20 +200,20 @@ object SteamGrid {
|
||||
val present = files(grid, target.id, slot)
|
||||
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
|
||||
val now = signature(theirs)
|
||||
val before = recorded(context, target, slot)
|
||||
val before = recorded(context, grid, target, slot)
|
||||
when {
|
||||
before == null && own != null && theirs == null -> write(context, target, slot, own)
|
||||
before == null || now != before -> {
|
||||
if (theirs != null) {
|
||||
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(context, target, slot, now)
|
||||
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(context, grid, target, slot, now)
|
||||
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
|
||||
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
|
||||
record(context, target, slot, now)
|
||||
record(context, grid, target, slot, now)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
|
||||
record(context, target, slot, "")
|
||||
record(context, grid, target, slot, "")
|
||||
}
|
||||
}
|
||||
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
|
||||
|
||||
@@ -84,18 +84,23 @@ object SteamGridDb {
|
||||
.filter { it.second > 0 && it.first > 0 }
|
||||
.maxByOrNull { it.second }?.first
|
||||
|
||||
fun download(key: String, id: Int, cache: File): Boolean {
|
||||
internal fun cacheName(slot: AddedGameArt.Slot): String =
|
||||
when (slot) {
|
||||
AddedGameArt.Slot.COVER -> "p.jpg"
|
||||
AddedGameArt.Slot.WIDE -> "header.jpg"
|
||||
AddedGameArt.Slot.BACKGROUND -> "hero.jpg"
|
||||
AddedGameArt.Slot.LOGO -> "logo.png"
|
||||
AddedGameArt.Slot.ICON -> "icon-sgdb.png"
|
||||
}
|
||||
|
||||
fun download(key: String, id: Int, cache: File, slots: Set<AddedGameArt.Slot> = AddedGameArt.Slot.values().toSet()): 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",
|
||||
)) {
|
||||
for (slot in AddedGameArt.Slot.values()) {
|
||||
if (slot !in slots) continue
|
||||
val local = cacheName(slot)
|
||||
val dst = File(cache, local)
|
||||
if (dst.isFile) continue
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue
|
||||
val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: continue
|
||||
val url = best(items, local.endsWith(".png")) ?: continue
|
||||
val bytes = AddedGameArt.get(url) ?: continue
|
||||
val tmp = File(cache, "$local.tmp")
|
||||
|
||||
@@ -15,6 +15,11 @@ object SteamRedists {
|
||||
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
|
||||
"228990" to ("d3dx9" to "DirectX (June 2010)"),
|
||||
)
|
||||
/** Redists a game's own install script runs on Windows that its shared depots don't name. */
|
||||
val SCRIPTED = mapOf(
|
||||
72850 to listOf("xact" to "DirectX XACT audio"),
|
||||
489830 to listOf("xact_x64" to "DirectX XACT audio (64-bit)"),
|
||||
)
|
||||
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
|
||||
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
|
||||
|
||||
@@ -23,6 +28,17 @@ object SteamRedists {
|
||||
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
/** Redists the game's own install script has Steam run on Windows, from its "Run Process" entries. */
|
||||
fun installScript(gameDir: File?): List<Pair<String, String>> =
|
||||
gameDir?.listFiles { f ->
|
||||
f.isFile && f.length() < 1L shl 20 && f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") }
|
||||
}.orEmpty().sortedBy { it.name }.flatMap { file ->
|
||||
val root = runCatching { com.droiddeck.launcher.input.KeyValues.parse(file.readText()) }.getOrNull() ?: return@flatMap emptyList()
|
||||
(root.child("InstallScript") ?: root).children("Run Process")
|
||||
.flatMap { run -> run.entries.mapNotNull { (it.second as? com.droiddeck.launcher.input.KeyValues.Node)?.string("process 1") } }
|
||||
.flatMap { path -> DependencyDetector.componentsFor(path).map { it to DependencyDetector.label(it) } }
|
||||
}.distinctBy { it.first }
|
||||
|
||||
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
|
||||
val text = try {
|
||||
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
|
||||
|
||||
@@ -3,12 +3,13 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
object WinCompSources {
|
||||
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
|
||||
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX }
|
||||
|
||||
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
|
||||
|
||||
@@ -49,6 +50,17 @@ object WinCompSources {
|
||||
|
||||
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
|
||||
|
||||
private val XBOX_KNOWN = Regex("""<KnownDependency\s+Name="([A-Za-z0-9_.-]{1,40})"""")
|
||||
// VC14 also registers the ARM64 runtime: launchers running as ARM64EC look for it and stop when it is missing.
|
||||
private val XBOX_COMPONENTS = mapOf("VC14" to listOf("vcredist2022", WinComponents.VCREDIST_ARM64))
|
||||
|
||||
/** Microsoft installs a PC Game Pass game's declared runtimes with it; VC14 is wanted by nearly all and is always added. */
|
||||
fun xbox(folder: File?): List<Pair<String, String>> {
|
||||
val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 }
|
||||
val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty()
|
||||
return (listOf("VC14") + declared).flatMap { name -> XBOX_COMPONENTS[name].orEmpty().map { it to name } }.distinctBy { it.first }
|
||||
}
|
||||
|
||||
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
|
||||
|
||||
|
||||
@@ -65,9 +77,10 @@ object WinCompSources {
|
||||
|
||||
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) ->
|
||||
val pairs = (if (local.isNotEmpty()) local.map { it.componentName to it.label }
|
||||
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()) +
|
||||
SteamRedists.installScript(gameDir) + SteamRedists.SCRIPTED[appId].orEmpty()
|
||||
return pairs.distinctBy { it.first.removeSuffix("_dll") }.map { (component, label) ->
|
||||
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
|
||||
}
|
||||
}
|
||||
@@ -94,6 +107,7 @@ object WinCompSources {
|
||||
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
|
||||
}
|
||||
}
|
||||
store == Store.XBOX -> xbox(folder).forEach { (component, original) -> out += Finding(component, original, Origin.XBOX, true) }
|
||||
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
|
||||
val parts = installer(command)
|
||||
val original = command.replace('\\', '/').substringAfterLast('/')
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.glasses
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/** Rokid Air/Max share one USB identity; the product string distinguishes them. */
|
||||
/** Rokid Air/Max share one USB identity; Max 2 has a separate product ID. */
|
||||
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
|
||||
override val controllable = true
|
||||
override val brightnessMax = BRIGHTNESS_STEPS
|
||||
@@ -33,6 +33,7 @@ internal class RokidModel private constructor(override val name: String, private
|
||||
companion object {
|
||||
const val VENDOR_ID = 0x04d2
|
||||
const val PRODUCT_ID = 0x162f
|
||||
const val MAX2_PRODUCT_ID = 0x2002
|
||||
const val BRIGHTNESS_STEPS = 10
|
||||
|
||||
// Vendor control requests, device recipient.
|
||||
@@ -52,9 +53,9 @@ internal class RokidModel private constructor(override val name: String, private
|
||||
|
||||
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
|
||||
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
|
||||
if (vid != VENDOR_ID || pid != PRODUCT_ID) return null
|
||||
if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) return null
|
||||
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
|
||||
return RokidModel(product, product.contains("Max", ignoreCase = true))
|
||||
return RokidModel(product, pid == MAX2_PRODUCT_ID || product.contains("Max", ignoreCase = true))
|
||||
}
|
||||
|
||||
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
|
||||
|
||||
@@ -50,8 +50,10 @@ object FrameGen {
|
||||
else -> context.getString(R.string.frame_gen_off)
|
||||
}
|
||||
|
||||
fun apply(context: Context, refreshHz: Float): String? {
|
||||
val mode = mode(context)
|
||||
fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz)
|
||||
|
||||
/** Arms [mode] in this process's compositor; the cloud player passes its own mode and frame-rate cap. */
|
||||
fun apply(context: Context, mode: Mode, refreshHz: Float): String? {
|
||||
when (mode.engine) {
|
||||
ENGINE_LSFG -> {
|
||||
val status = Lossless.sync(context)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
object TpUbwcHint {
|
||||
const val ENV = "FD_DEV_FEATURES"
|
||||
@@ -8,31 +10,43 @@ object TpUbwcHint {
|
||||
|
||||
@Volatile private var resolved = false
|
||||
@Volatile private var cached: String? = null
|
||||
@Volatile var activeMode: String? = null
|
||||
private set
|
||||
|
||||
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
|
||||
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
|
||||
// match the system driver. A mismatch corrupts Android UI.
|
||||
// Samsung's One UI firmware sets it whatever blob version it reports.
|
||||
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
|
||||
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false,
|
||||
mode: String = SessionPrefs.TP_UBWC_AUTO): String? {
|
||||
if (model != 740) return null
|
||||
if (mode == SessionPrefs.TP_UBWC_ON) return "enable_tp_ubwc_flag_hint=1"
|
||||
if (mode == SessionPrefs.TP_UBWC_OFF) return "enable_tp_ubwc_flag_hint=0"
|
||||
if (samsung) return "enable_tp_ubwc_flag_hint=1"
|
||||
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
|
||||
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
|
||||
val parts = systemDriverVersion?.split('.') ?: return null
|
||||
val blob = parts.getOrNull(1)?.toIntOrNull() ?: return null
|
||||
val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0
|
||||
val on = blob >= 700 || (blob == 676 && patch >= 76)
|
||||
return "enable_tp_ubwc_flag_hint=" + if (on) "1" else "0"
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun forThisDevice(): String? {
|
||||
fun forThisDevice(context: Context): String? {
|
||||
if (resolved) return cached
|
||||
val mode = SessionPrefs.tpUbwcMode(context)
|
||||
val gpu = GpuInfo.detect()
|
||||
val model = gpu.model
|
||||
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
|
||||
cached = features(model, version, gpu.oneUi8Gen2)
|
||||
cached = features(model, version, gpu.oneUi8Gen2, mode)
|
||||
activeMode = mode
|
||||
resolved = true
|
||||
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}, mode $mode: ${cached ?: "driver default"}")
|
||||
return cached
|
||||
}
|
||||
|
||||
fun applyToProcess() {
|
||||
val value = forThisDevice() ?: return
|
||||
fun restartRequired(context: Context): Boolean = resolved && activeMode != SessionPrefs.tpUbwcMode(context)
|
||||
|
||||
fun applyToProcess(context: Context) {
|
||||
val value = forThisDevice(context) ?: return
|
||||
runCatching { android.system.Os.setenv(ENV, value, true) }
|
||||
.onFailure { Log.w(TAG, "setenv $ENV failed: ${it.javaClass.simpleName}") }
|
||||
}
|
||||
|
||||
@@ -157,6 +157,56 @@ object ControllerPrefs {
|
||||
prefs(context).edit().remove("layout.${width}x$height").apply()
|
||||
}
|
||||
|
||||
fun screenKeysShown(context: Context): Boolean = prefs(context).getBoolean("screenKeysShown", true)
|
||||
|
||||
fun setScreenKeysShown(context: Context, shown: Boolean) {
|
||||
prefs(context).edit().putBoolean("screenKeysShown", shown).apply()
|
||||
}
|
||||
|
||||
fun screenKeys(context: Context): List<ScreenKey> = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty())
|
||||
|
||||
fun setScreenKeys(context: Context, keys: List<ScreenKey>) {
|
||||
prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply()
|
||||
}
|
||||
|
||||
fun encodeScreenKeys(keys: List<ScreenKey>): String = keys.joinToString(";") {
|
||||
"${it.codes.joinToString("+")}:${it.x},${it.y},${it.size}:${escape(it.glyph)}:${escape(it.text)}:${escape(it.image)}:${it.opacity}:${it.shape}:${if (it.toggle) 1 else 0}:${if (it.labels) 1 else 0}"
|
||||
}
|
||||
|
||||
fun decodeScreenKeys(raw: String): List<ScreenKey> = raw.split(';').mapNotNull { entry ->
|
||||
val parts = entry.split(':')
|
||||
if (parts.size < 2) return@mapNotNull null
|
||||
val keys = parts[0].split('+').mapNotNull { it.toIntOrNull() }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val nums = parts[1].split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 3 } ?: return@mapNotNull null
|
||||
ScreenKey(
|
||||
keys, nums[0], nums[1], nums[2].toInt(),
|
||||
parts.getOrNull(2)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(3)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(4)?.let(::unescape).orEmpty(),
|
||||
parts.getOrNull(5)?.toFloatOrNull()?.coerceIn(0.2f, 1f) ?: 1f,
|
||||
parts.getOrNull(6)?.toIntOrNull()?.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) ?: SCREEN_KEY_PILL,
|
||||
parts.getOrNull(7) == "1",
|
||||
parts.getOrNull(8) != "0",
|
||||
)
|
||||
}
|
||||
|
||||
fun screenLayouts(context: Context): Map<String, String> =
|
||||
prefs(context).all.mapNotNull { (key, value) ->
|
||||
if (!key.startsWith("screenLayout.") || value !is String) null else key.removePrefix("screenLayout.") to value
|
||||
}.toMap()
|
||||
|
||||
fun saveScreenLayout(context: Context, name: String, raw: String) {
|
||||
prefs(context).edit().putString("screenLayout.$name", raw).apply()
|
||||
}
|
||||
|
||||
fun deleteScreenLayout(context: Context, name: String) {
|
||||
prefs(context).edit().remove("screenLayout.$name").apply()
|
||||
}
|
||||
|
||||
private fun escape(value: String) = value.replace("%", "%25").replace(";", "%3B").replace(":", "%3A").replace(",", "%2C")
|
||||
|
||||
private fun unescape(value: String) = value.replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("rumble", true).apply()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class DirectTouchHold(
|
||||
private var y = 0f
|
||||
private var consumed = false
|
||||
private var pressed = false
|
||||
private var delayMs = 0L
|
||||
private val release = Runnable {
|
||||
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
|
||||
pressed = false
|
||||
@@ -28,6 +29,11 @@ class DirectTouchHold(
|
||||
handler.postDelayed(release, 50L)
|
||||
}
|
||||
|
||||
fun configure(config: TouchGestureConfig) {
|
||||
cancel()
|
||||
delayMs = if (config.longPressEnabled) config.longPressDelay.toLong().coerceAtLeast(1L) else 0L
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(release)
|
||||
@@ -40,7 +46,7 @@ class DirectTouchHold(
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
cancel()
|
||||
x = event.x; y = event.y
|
||||
handler.postDelayed(hold, 500L)
|
||||
if (delayMs > 0L) handler.postDelayed(hold, delayMs)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
|
||||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.view.MotionEvent
|
||||
|
||||
/** Keeps split Android touch targets from restarting one shared gesture recognizer. */
|
||||
internal class GestureTouchStreamGate {
|
||||
enum class Source { SURFACE, SCREEN_KEYS }
|
||||
|
||||
private var owner: Source? = null
|
||||
|
||||
fun accepts(source: Source, event: MotionEvent): Boolean {
|
||||
if (owner == null && event.actionMasked == MotionEvent.ACTION_DOWN) owner = source
|
||||
val accepted = owner == source
|
||||
if (accepted && (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL)) {
|
||||
owner = null
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
fun reset() { owner = null }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.input;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
@@ -308,10 +309,10 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Z:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.L4, pressed);
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.R4 : DeckControls.L4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_C:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.R4, pressed);
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
@@ -573,6 +574,12 @@ public final class PadBridge {
|
||||
return slot == 0 ? state : playerStates[slot];
|
||||
}
|
||||
|
||||
private static boolean isNovaController(InputDevice device) {
|
||||
return "Retroid Pocket Nova".equalsIgnoreCase(Build.MODEL)
|
||||
&& device.getVendorId() == 0x2022
|
||||
&& (device.getProductId() == 0x3001 || device.getProductId() == 0x3002);
|
||||
}
|
||||
|
||||
private boolean isPrimary(KeyEvent event) {
|
||||
int id = event.getDevice().getId();
|
||||
int first = firstPlayerSlot();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
data class PcKey(
|
||||
val label: String,
|
||||
val code: Int,
|
||||
val weight: Float = 1f,
|
||||
val shifted: String? = null,
|
||||
@StringRes val labelRes: Int = 0,
|
||||
)
|
||||
|
||||
internal fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
|
||||
|
||||
internal fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
|
||||
|
||||
val PC_KEY_ROWS: List<List<PcKey>> = listOf(
|
||||
listOf(
|
||||
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
|
||||
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
|
||||
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
|
||||
),
|
||||
listOf(
|
||||
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
|
||||
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
|
||||
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
|
||||
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
|
||||
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
|
||||
k("\\", 43, 1.5f, shifted = "|"),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
|
||||
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
|
||||
k(R.string.pckb_key_enter, 28, 2.25f),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
|
||||
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
|
||||
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
|
||||
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
|
||||
),
|
||||
)
|
||||
|
||||
val PC_MODIFIERS: Set<Int> = setOf(42, 54, 29, 97, 56, 100, 125)
|
||||
|
||||
val PC_KEYS: Map<Int, PcKey> = PC_KEY_ROWS.flatten().associateBy { it.code }
|
||||
|
||||
fun pcKeyLabel(context: Context, code: Int): String {
|
||||
val key = PC_KEYS[code] ?: return code.toString()
|
||||
val text = if (key.labelRes != 0) context.getString(key.labelRes) else key.label
|
||||
return if (text.length == 1 && text[0].isLetter()) text.uppercase() else text
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
|
||||
fun screenKeyLabel(context: Context, codes: List<Int>): String =
|
||||
codes.filter { it != 0 }.joinToString("+") { short(context, it) }
|
||||
|
||||
private fun short(context: Context, code: Int): String = when (code) {
|
||||
28 -> "⏎"
|
||||
14 -> "⌫"
|
||||
15 -> "Tab"
|
||||
57 -> "Space"
|
||||
58 -> "Caps"
|
||||
42, 54 -> "Shift"
|
||||
29, 97 -> "Ctrl"
|
||||
56 -> "Alt"
|
||||
100 -> "AltGr"
|
||||
125 -> "Super"
|
||||
else -> pcKeyLabel(context, code)
|
||||
}
|
||||
|
||||
const val SCREEN_KEY_PILL = 0
|
||||
const val SCREEN_KEY_ROUND = 1
|
||||
const val SCREEN_KEY_CIRCLE = 2
|
||||
const val SCREEN_KEY_RECT = 3
|
||||
const val SCREEN_KEY_TRIGGER = 4
|
||||
const val SCREEN_KEY_DPAD = 5
|
||||
const val SCREEN_KEY_STICK = 6
|
||||
|
||||
val SCREEN_KEY_DPAD_DIRS = listOf("↑", "→", "↓", "←")
|
||||
val SCREEN_KEY_STICK_DIRS = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
|
||||
|
||||
fun screenKeyDirections(shape: Int): List<String> = when (shape) {
|
||||
SCREEN_KEY_DPAD -> SCREEN_KEY_DPAD_DIRS
|
||||
SCREEN_KEY_STICK -> SCREEN_KEY_STICK_DIRS
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
val SCREEN_KEY_SHAPES =
|
||||
listOf(
|
||||
SCREEN_KEY_PILL, SCREEN_KEY_ROUND, SCREEN_KEY_CIRCLE, SCREEN_KEY_RECT,
|
||||
SCREEN_KEY_TRIGGER, SCREEN_KEY_DPAD, SCREEN_KEY_STICK,
|
||||
)
|
||||
|
||||
data class ScreenKey(
|
||||
val codes: List<Int>,
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val size: Int,
|
||||
val glyph: String = "",
|
||||
val text: String = "",
|
||||
val image: String = "",
|
||||
val opacity: Float = 1f,
|
||||
val shape: Int = SCREEN_KEY_PILL,
|
||||
val toggle: Boolean = false,
|
||||
val labels: Boolean = true,
|
||||
) {
|
||||
fun shownLabel(context: Context): String = when {
|
||||
text.isNotEmpty() -> text
|
||||
glyph.isNotEmpty() -> glyph
|
||||
else -> screenKeyLabel(context, codes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
|
||||
object ScreenKeyIcons {
|
||||
fun has(code: Int): Boolean = code in SET
|
||||
|
||||
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
|
||||
|
||||
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
|
||||
if (!has(code)) return false
|
||||
val h = box / 2f
|
||||
val stroke = box * 0.085f
|
||||
val path = Path()
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = stroke
|
||||
paint.strokeCap = Paint.Cap.ROUND
|
||||
paint.strokeJoin = Paint.Join.ROUND
|
||||
when (code) {
|
||||
28 -> {
|
||||
path.moveTo(cx - 0.34f * h, cy - 0.22f * h)
|
||||
path.lineTo(cx + 0.30f * h, cy - 0.22f * h)
|
||||
path.lineTo(cx + 0.30f * h, cy + 0.24f * h)
|
||||
path.lineTo(cx - 0.04f * h, cy + 0.24f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
path.reset()
|
||||
path.moveTo(cx + 0.12f * h, cy + 0.06f * h)
|
||||
path.lineTo(cx - 0.04f * h, cy + 0.24f * h)
|
||||
path.lineTo(cx + 0.12f * h, cy + 0.42f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
14 -> {
|
||||
path.moveTo(cx - 0.72f * h, cy)
|
||||
path.lineTo(cx - 0.10f * h, cy - 0.50f * h)
|
||||
path.lineTo(cx + 0.74f * h, cy - 0.50f * h)
|
||||
path.lineTo(cx + 0.74f * h, cy + 0.50f * h)
|
||||
path.lineTo(cx - 0.10f * h, cy + 0.50f * h)
|
||||
path.close()
|
||||
canvas.drawPath(path, paint)
|
||||
path.reset()
|
||||
path.moveTo(cx + 0.10f * h, cy - 0.20f * h)
|
||||
path.lineTo(cx + 0.44f * h, cy + 0.20f * h)
|
||||
path.moveTo(cx + 0.44f * h, cy - 0.20f * h)
|
||||
path.lineTo(cx + 0.10f * h, cy + 0.20f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
15 -> {
|
||||
path.moveTo(cx - 0.72f * h, cy)
|
||||
path.lineTo(cx + 0.44f * h, cy)
|
||||
path.moveTo(cx + 0.14f * h, cy - 0.30f * h)
|
||||
path.lineTo(cx + 0.46f * h, cy)
|
||||
path.lineTo(cx + 0.14f * h, cy + 0.30f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
path.reset()
|
||||
path.moveTo(cx + 0.70f * h, cy - 0.48f * h)
|
||||
path.lineTo(cx + 0.70f * h, cy + 0.48f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
57 -> {
|
||||
path.moveTo(cx - 0.64f * h, cy - 0.22f * h)
|
||||
path.lineTo(cx - 0.64f * h, cy + 0.20f * h)
|
||||
path.lineTo(cx + 0.64f * h, cy + 0.20f * h)
|
||||
path.lineTo(cx + 0.64f * h, cy - 0.22f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
42, 54 -> {
|
||||
paint.style = Paint.Style.FILL
|
||||
path.moveTo(cx, cy - 0.55f * h)
|
||||
path.lineTo(cx + 0.62f * h, cy + 0.02f * h)
|
||||
path.lineTo(cx + 0.26f * h, cy + 0.02f * h)
|
||||
path.lineTo(cx + 0.26f * h, cy + 0.32f * h)
|
||||
path.lineTo(cx - 0.26f * h, cy + 0.32f * h)
|
||||
path.lineTo(cx - 0.26f * h, cy + 0.02f * h)
|
||||
path.lineTo(cx - 0.62f * h, cy + 0.02f * h)
|
||||
path.close()
|
||||
canvas.drawPath(path, paint)
|
||||
path.reset()
|
||||
path.moveTo(cx - 0.26f * h, cy + 0.44f * h)
|
||||
path.lineTo(cx + 0.26f * h, cy + 0.44f * h)
|
||||
path.lineTo(cx + 0.26f * h, cy + 0.58f * h)
|
||||
path.lineTo(cx - 0.26f * h, cy + 0.58f * h)
|
||||
path.close()
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
29, 97 -> {
|
||||
path.moveTo(cx - 0.58f * h, cy + 0.26f * h)
|
||||
path.lineTo(cx, cy - 0.28f * h)
|
||||
path.lineTo(cx + 0.58f * h, cy + 0.26f * h)
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
125 -> {
|
||||
paint.style = Paint.Style.FILL
|
||||
val gap = 0.07f * h
|
||||
val side = 0.34f * h
|
||||
for (dxi in -1..1 step 2) for (dyi in -1..1 step 2) {
|
||||
val left = cx + dxi * (gap + side / 2f) - side / 2f
|
||||
val top = cy + dyi * (gap + side / 2f) - side / 2f
|
||||
path.addRect(left, top, left + side, top + side, Path.Direction.CW)
|
||||
}
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
103, 108, 105, 106 -> {
|
||||
paint.style = Paint.Style.FILL
|
||||
val r = 0.52f * h
|
||||
when (code) {
|
||||
103 -> {
|
||||
path.moveTo(cx, cy - r)
|
||||
path.lineTo(cx + r * 0.80f, cy + r * 0.55f)
|
||||
path.lineTo(cx - r * 0.80f, cy + r * 0.55f)
|
||||
}
|
||||
|
||||
108 -> {
|
||||
path.moveTo(cx, cy + r)
|
||||
path.lineTo(cx + r * 0.80f, cy - r * 0.55f)
|
||||
path.lineTo(cx - r * 0.80f, cy - r * 0.55f)
|
||||
}
|
||||
|
||||
105 -> {
|
||||
path.moveTo(cx - r, cy)
|
||||
path.lineTo(cx + r * 0.55f, cy - r * 0.80f)
|
||||
path.lineTo(cx + r * 0.55f, cy + r * 0.80f)
|
||||
}
|
||||
|
||||
else -> {
|
||||
path.moveTo(cx + r, cy)
|
||||
path.lineTo(cx - r * 0.55f, cy - r * 0.80f)
|
||||
path.lineTo(cx - r * 0.55f, cy + r * 0.80f)
|
||||
}
|
||||
}
|
||||
path.close()
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.strokeCap = Paint.Cap.BUTT
|
||||
paint.strokeJoin = Paint.Join.MITER
|
||||
return true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,140 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ICP_NAMES: Map<Int, String> = mapOf(
|
||||
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
|
||||
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
|
||||
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
|
||||
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
|
||||
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
|
||||
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
|
||||
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
|
||||
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
|
||||
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
|
||||
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
|
||||
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
|
||||
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
|
||||
21 to "KEY_Y", 44 to "KEY_Z",
|
||||
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
|
||||
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
|
||||
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
|
||||
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
|
||||
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
|
||||
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
|
||||
)
|
||||
|
||||
private val ICP_CODES: Map<String, Int> = ICP_NAMES.entries.associate { (code, name) -> name to code }
|
||||
|
||||
private fun icpShape(shape: Int): String = when (shape) {
|
||||
SCREEN_KEY_CIRCLE -> "CIRCLE"
|
||||
SCREEN_KEY_RECT -> "RECT"
|
||||
SCREEN_KEY_ROUND -> "ROUND_RECT"
|
||||
SCREEN_KEY_DPAD -> "ROUND_RECT"
|
||||
SCREEN_KEY_STICK -> "ROUND_RECT"
|
||||
SCREEN_KEY_TRIGGER -> "ROUND_RECT"
|
||||
else -> "ROUND_RECT"
|
||||
}
|
||||
|
||||
private fun screenKeyShape(shape: String): Int = when (shape) {
|
||||
"CIRCLE" -> SCREEN_KEY_CIRCLE
|
||||
"RECT" -> SCREEN_KEY_RECT
|
||||
"SQUARE" -> SCREEN_KEY_ROUND
|
||||
else -> SCREEN_KEY_PILL
|
||||
}
|
||||
|
||||
fun exportScreenLayout(keys: List<ScreenKey>, widthPx: Int, density: Float): String {
|
||||
val snap = widthPx / 100f
|
||||
val elements = JSONArray()
|
||||
keys.forEach { key ->
|
||||
val binds = JSONArray()
|
||||
val directional = key.shape == SCREEN_KEY_DPAD || key.shape == SCREEN_KEY_STICK
|
||||
key.codes.forEach { code ->
|
||||
val name = ICP_NAMES[code]
|
||||
if (name != null) binds.put(name) else if (directional) binds.put("")
|
||||
}
|
||||
if (binds.length() == 0) return@forEach
|
||||
val shape = icpShape(key.shape)
|
||||
val sizePx = key.size * density
|
||||
val scale = when {
|
||||
key.shape == SCREEN_KEY_DPAD -> sizePx * 2.3f / (14f * snap)
|
||||
key.shape == SCREEN_KEY_STICK -> sizePx * 2.3f / (14f * snap)
|
||||
shape == "CIRCLE" -> sizePx / (6f * snap)
|
||||
else -> sizePx / (4f * snap)
|
||||
}
|
||||
elements.put(
|
||||
JSONObject()
|
||||
.put(
|
||||
"type",
|
||||
when (key.shape) {
|
||||
SCREEN_KEY_DPAD -> "D_PAD"
|
||||
SCREEN_KEY_STICK -> "STICK"
|
||||
else -> "BUTTON"
|
||||
},
|
||||
)
|
||||
.put("shape", shape)
|
||||
.put("customColor", 0)
|
||||
.put("bindings", binds)
|
||||
.put("scale", scale)
|
||||
.put("x", key.x)
|
||||
.put("y", key.y)
|
||||
.put("toggleSwitch", key.toggle)
|
||||
.put("swipeable", false)
|
||||
.put("text", key.text)
|
||||
.put("iconId", 0)
|
||||
.apply { if (key.opacity < 1f) put("opacity", key.opacity) },
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("id", 0)
|
||||
.put("name", "DroidDeck")
|
||||
.put("cursorSpeed", 1.5f)
|
||||
.put("elements", elements)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun importScreenLayout(raw: String, widthPx: Int, density: Float): List<ScreenKey>? {
|
||||
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val elements = root.optJSONArray("elements") ?: return null
|
||||
val snap = widthPx / 100f
|
||||
val keys = mutableListOf<ScreenKey>()
|
||||
for (i in 0 until elements.length()) {
|
||||
val element = elements.optJSONObject(i) ?: continue
|
||||
val type = element.optString("type", "BUTTON")
|
||||
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
|
||||
val binds = element.optJSONArray("bindings") ?: continue
|
||||
val raw = (0 until binds.length()).map { ICP_CODES[binds.optString(it)] ?: 0 }
|
||||
val codes = if (type == "D_PAD" || type == "STICK") {
|
||||
raw.dropLastWhile { it == 0 }.ifEmpty { listOf(0) }
|
||||
} else {
|
||||
raw.filter { it != 0 }
|
||||
}
|
||||
if (codes.isEmpty()) continue
|
||||
val dpad = type == "D_PAD"
|
||||
val stick = type == "STICK"
|
||||
val shape = when {
|
||||
dpad -> SCREEN_KEY_DPAD
|
||||
stick -> SCREEN_KEY_STICK
|
||||
else -> screenKeyShape(element.optString("shape"))
|
||||
}
|
||||
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it > 0f } ?: 1f
|
||||
val sizePx = when {
|
||||
dpad || stick -> 14f * snap * scale / 2.3f
|
||||
shape == SCREEN_KEY_CIRCLE -> 6f * snap * scale
|
||||
else -> 4f * snap * scale
|
||||
}
|
||||
keys += ScreenKey(
|
||||
codes = codes,
|
||||
x = element.optDouble("x", 0.5).toFloat().coerceIn(0f, 1f),
|
||||
y = element.optDouble("y", 0.5).toFloat().coerceIn(0f, 1f),
|
||||
size = (sizePx / density).roundToInt().coerceIn(ScreenKeys.MIN_SIZE, ScreenKeys.MAX_SIZE),
|
||||
text = element.optString("text").take(24),
|
||||
opacity = element.optDouble("opacity", 1.0).toFloat().coerceIn(0.2f, 1f),
|
||||
shape = shape,
|
||||
toggle = element.optBoolean("toggleSwitch"),
|
||||
)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -93,7 +93,10 @@ class SteamTouchControls(
|
||||
private var opened = 0
|
||||
private var elements: List<Element> = emptyList()
|
||||
private var tint = Color.WHITE
|
||||
private var alphaScale = 0.45f
|
||||
private var alphaScale = 0.99f
|
||||
var style: Pair<Int, Float> = Color.WHITE to 0.99f
|
||||
private set
|
||||
var onStyle: (() -> Unit)? = null
|
||||
|
||||
private class Visual(val ref: SteamTouchBindings.Ref?, val binding: SteamTouchBindings.Binding?)
|
||||
private var visuals: Map<Int, List<Visual>> = emptyMap()
|
||||
@@ -191,6 +194,8 @@ class SteamTouchControls(
|
||||
private fun applyColor(color: FloatArray?) {
|
||||
tint = if (color != null) Color.rgb((color[0] * 255).toInt(), (color[1] * 255).toInt(), (color[2] * 255).toInt()) else Color.WHITE
|
||||
alphaScale = ((color?.get(3) ?: 0.45f) * 2.2f).coerceIn(0.15f, 1f)
|
||||
style = tint to alphaScale
|
||||
onStyle?.invoke()
|
||||
}
|
||||
|
||||
private fun rebuildVisuals() {
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import java.io.File
|
||||
|
||||
object BattleNet {
|
||||
private const val TAG = "BattleNet"
|
||||
|
||||
const val APP_ID = 2596113170L
|
||||
|
||||
const val FEX_REQUEST = "battlenet-fex.request"
|
||||
const val FEX_RESULT = "battlenet-fex.result"
|
||||
|
||||
const val PROGRAM = "/usr/local/bin/droiddeck-battlenet"
|
||||
|
||||
class Title(val uid: String, val name: String, val code: String, val steam: Int? = null, val shop: String? = null)
|
||||
|
||||
internal val TITLES = listOf(
|
||||
Title("wow", "World of Warcraft", "WoW", shop = "world-of-warcraft-midnight"),
|
||||
Title("wow_classic", "World of Warcraft Classic", "WoWC", shop = "world-of-warcraft-burning-crusade-classic-anniversary-edition-upgrades"),
|
||||
Title("wow_classic_era", "World of Warcraft Classic", "WoWC"),
|
||||
Title("wow_classic_anniversary", "World of Warcraft Classic", "WoWC"),
|
||||
Title("diablo3", "Diablo III", "D3", shop = "diablo-iii"),
|
||||
Title("d3cn", "Diablo III", "D3CN", shop = "diablo-iii"),
|
||||
Title("s2", "StarCraft II", "S2", shop = "starcraft-ii"),
|
||||
Title("s1", "StarCraft", "S1", shop = "starcraft-remastered"),
|
||||
Title("hs_beta", "Hearthstone", "WTCG", shop = "hearthstone"),
|
||||
Title("heroes", "Heroes of the Storm", "Hero", shop = "heroes-of-the-storm"),
|
||||
Title("prometheus", "Overwatch 2", "Pro", 2357570, shop = "overwatch"),
|
||||
Title("w3", "Warcraft III: Reforged", "W3", shop = "warcraft-3-reforged"),
|
||||
Title("viper", "Call of Duty: Black Ops 4", "VIPR", shop = "call-of-duty-black-ops-4"),
|
||||
Title("odin", "Call of Duty: Modern Warfare", "ODIN", 2000950, shop = "call-of-duty-modern-warfare"),
|
||||
Title("lazarus", "Call of Duty: Modern Warfare 2 Campaign Remastered", "LAZR", shop = "call-of-duty-modern-warfare-2-campaign-remastered"),
|
||||
Title("zeus", "Call of Duty: Black Ops Cold War", "ZEUS", shop = "call-of-duty-black-ops-cold-war"),
|
||||
Title("fore", "Call of Duty: Vanguard", "FORE", shop = "call-of-duty-vanguard"),
|
||||
Title("auks", "Call of Duty: Modern Warfare II", "AUKS", shop = "call-of-duty-modern-warfare-ii"),
|
||||
Title("pinta", "Call of Duty: Modern Warfare III", "PNTA", 2519060, shop = "call-of-duty-modern-warfare-iii"),
|
||||
Title("wlby", "Crash Bandicoot 4: It's About Time", "WLBY", 1378990, shop = "crash-bandicoot-4"),
|
||||
Title("osi", "Diablo II: Resurrected", "OSI"),
|
||||
Title("rtro", "Blizzard Arcade Collection", "RTRO", shop = "blizzard-arcade-collection"),
|
||||
Title("anbs", "Diablo Immortal", "ANBS", shop = "diablo-immortal"),
|
||||
Title("fenris", "Diablo IV", "Fen", 2344520, shop = "diablo-iv"),
|
||||
Title("fen", "Diablo IV", "Fen", 2344520, shop = "diablo-iv"),
|
||||
Title("d1", "Diablo", "D1", shop = "diablo"),
|
||||
Title("w1", "Warcraft: Orcs & Humans", "W1", shop = "warcraft-orcs-and-humans"),
|
||||
Title("w2", "Warcraft II: Battle.net Edition", "W2", shop = "warcraft-2-battle-net-edition"),
|
||||
Title("w1r", "Warcraft: Remastered", "W1R", shop = "warcraft-1-remastered"),
|
||||
Title("w2r", "Warcraft II: Remastered", "W2R", shop = "warcraft-2-remastered"),
|
||||
Title("gryphon", "Warcraft Rumble", "GRY", shop = "warcraft-rumble"),
|
||||
Title("aris", "Doom: The Dark Ages", "ARIS", 3017860, shop = "doom-the-dark-ages"),
|
||||
Title("scorpio", "Sea of Thieves", "SCOR", 1172620, shop = "sea-of-thieves"),
|
||||
Title("arkansas", "The Outer Worlds 2", "ARK", 1449110, shop = "the-outer-worlds-2"),
|
||||
Title("libra", "Tony Hawk's Pro Skater 3 + 4", "LBRA", 2545710),
|
||||
Title("aqua", "Avowed", "AQUA", 2457220, shop = "avowed"),
|
||||
)
|
||||
|
||||
val STORE: List<Title> get() = TITLES.distinctBy { it.code }.filter { it.uid != "d3cn" && it.code != "WoWC" }
|
||||
|
||||
fun notInstalled(title: Title, installedIds: Collection<String?>) =
|
||||
installedIds.mapNotNull { id -> id?.let(::title)?.code }.none { it == title.code }
|
||||
|
||||
private const val COVER_RETRY_MS = 24 * 3600_000L
|
||||
|
||||
private fun coverDir(context: Context, title: Title) =
|
||||
File(context.filesDir, "battlenet-art/${(TITLES.firstOrNull { it.code == title.code } ?: title).uid}")
|
||||
|
||||
fun cover(context: Context, title: Title): File? = art(context, title).cover
|
||||
|
||||
private val coverLock = Any()
|
||||
|
||||
fun fetchCovers(context: Context, titles: List<Title> = TITLES.distinctBy { it.code }.filter { it.uid != "d3cn" }): Boolean = synchronized(coverLock) {
|
||||
val art = com.droiddeck.launcher.frontend.AddedGameArt
|
||||
val key = com.droiddeck.launcher.frontend.SteamGridDb.key(context)
|
||||
var got = false
|
||||
for (title in titles) {
|
||||
val dir = coverDir(context, title)
|
||||
val tried = File(dir, ".tried")
|
||||
val wide = File(dir, BLIZZARD)
|
||||
val done = File(dir, "p.jpg").isFile && (title.shop == null || wide.isFile)
|
||||
if (done || System.currentTimeMillis() - tried.lastModified() < COVER_RETRY_MS) continue
|
||||
dir.mkdirs()
|
||||
if (title.steam != null && runCatching { art.download(title.steam.toString(), dir) }.getOrDefault(false)) got = true
|
||||
if (title.shop != null && !wide.isFile) {
|
||||
val cover = File(dir, BLIZZARD_COVER).takeUnless { File(dir, "p.jpg").isFile }
|
||||
if (runCatching { com.droiddeck.launcher.stores.battlenet.BlizzardArt.fetch(title.shop, wide, cover) }
|
||||
.onFailure { Log.w(TAG, "Blizzard art for ${title.uid}: ${it.javaClass.simpleName}") }.getOrDefault(false)) got = true
|
||||
}
|
||||
if (!File(dir, "p.jpg").isFile && !File(dir, BLIZZARD_COVER).isFile && key.isNotEmpty()) {
|
||||
val id = runCatching { com.droiddeck.launcher.frontend.SteamGridDb.gameId(key, title.steam, title.name) }.getOrNull()
|
||||
if (id != null && runCatching { com.droiddeck.launcher.frontend.SteamGridDb.download(key, id, dir) }.getOrDefault(false)) got = true
|
||||
}
|
||||
runCatching { tried.writeText("") ; tried.setLastModified(System.currentTimeMillis()) }
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
private val LOCALES = setOf(
|
||||
"enus", "engb", "dede", "eses", "esmx", "frfr", "itit", "kokr", "plpl", "ptbr", "ptpt", "ruru", "zhcn", "zhtw", "jajp", "thth", "trtr", "arsa",
|
||||
)
|
||||
|
||||
private val NOT_GAMES = setOf("agent", "bna", "battle.net")
|
||||
|
||||
class Game(val uid: String, val name: String, val code: String, val folder: File) {
|
||||
val title: Title get() = title(uid) ?: Title(uid, name, code)
|
||||
}
|
||||
|
||||
class Art(val cover: File?, val header: File?)
|
||||
|
||||
private const val BLIZZARD = "blizzard.jpg"
|
||||
private const val BLIZZARD_COVER = "p-blizzard.jpg"
|
||||
|
||||
fun art(context: Context, title: Title): Art {
|
||||
val dir = coverDir(context, title)
|
||||
fun first(vararg names: String) = names.map { File(dir, it) }.firstOrNull { it.isFile }
|
||||
return Art(first("p.jpg", BLIZZARD_COVER), first(BLIZZARD, "header.jpg"))
|
||||
}
|
||||
|
||||
fun prefix(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/compatdata/$APP_ID")
|
||||
|
||||
fun client(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe")
|
||||
|
||||
fun installed(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe").isFile
|
||||
|
||||
fun profile(context: Context): Pair<String, String>? =
|
||||
(APP_ID.toString() to context.getString(com.droiddeck.launcher.R.string.bnet_name)).takeIf { installed(context) }
|
||||
|
||||
fun icon(context: Context): File? {
|
||||
val out = File(context.filesDir, "battlenet-art/launcher/icon.png")
|
||||
if (out.isFile) return out
|
||||
val exe = client(context).takeIf { it.isFile } ?: return null
|
||||
val bitmap = runCatching { com.droiddeck.launcher.files.PeIconExtractor.extract(exe) }.getOrNull() ?: return null
|
||||
return runCatching {
|
||||
out.parentFile?.mkdirs()
|
||||
val tmp = File(out.path + ".tmp")
|
||||
tmp.outputStream().use { check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)) }
|
||||
check(tmp.renameTo(out))
|
||||
out
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun launcherEntry(installed: Boolean, art: File? = null): com.droiddeck.launcher.frontend.Library.SteamGame? =
|
||||
if (!installed) null
|
||||
else com.droiddeck.launcher.frontend.Library.SteamGame(
|
||||
appId = APP_ID.toInt(), name = com.droiddeck.launcher.stores.Store.BATTLENET.label, art = art,
|
||||
library = com.droiddeck.launcher.frontend.Library.BATTLENET_LAUNCHER, gameId = APP_ID,
|
||||
source = com.droiddeck.launcher.stores.Store.BATTLENET.id,
|
||||
)
|
||||
|
||||
fun fexInUse(context: Context): String? = ComponentsManager.gameComponents(context, APP_ID.toString())["fex"]
|
||||
|
||||
internal fun title(uid: String): Title? {
|
||||
val id = uid.lowercase()
|
||||
TITLES.firstOrNull { it.uid == id }?.let { return it }
|
||||
val base = id.substringBeforeLast('_', "")
|
||||
return if (base.isNotEmpty() && id.substringAfterLast('_') in LOCALES) TITLES.firstOrNull { it.uid == base } else null
|
||||
}
|
||||
|
||||
fun games(context: Context): List<Game> {
|
||||
val pfx = File(prefix(context), "pfx")
|
||||
val entries = runCatching { File(pfx, "system.reg").useLines { uninstallEntries(it) } }.getOrDefault(emptyList()) +
|
||||
runCatching { productDbEntries(File(pfx, "drive_c/ProgramData/Battle.net/Agent/product.db").readBytes()) }.getOrDefault(emptyList())
|
||||
val found = LinkedHashMap<String, Game>()
|
||||
for ((uid, path) in entries) {
|
||||
val title = title(uid)
|
||||
if (title == null) {
|
||||
if (uid.lowercase() !in NOT_GAMES) Log.i(TAG, "not listed: $uid (no launch code known)")
|
||||
continue
|
||||
}
|
||||
if (title.code in found) continue
|
||||
val folder = hostFolder(context, path)?.takeIf { it.isDirectory } ?: continue
|
||||
found[title.code] = Game(title.uid, title.name, title.code, folder)
|
||||
}
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
internal fun hostFolder(context: Context, path: String): File? {
|
||||
val drive = Regex("""^([CcZz]):[\\/](.*)$""").matchEntire(path.trim()) ?: return null
|
||||
val relative = drive.groupValues[2].replace('\\', '/').trim('/')
|
||||
if (relative.isEmpty() || relative.split('/').any { it == ".." }) return null
|
||||
return if (drive.groupValues[1].equals("c", ignoreCase = true)) File(prefix(context), "pfx/drive_c/$relative")
|
||||
else com.droiddeck.launcher.frontend.AddedGames.hostPath(context, "/$relative")
|
||||
}
|
||||
|
||||
private val KEY = Regex("""^\[(.+)\](?:\s+\d+)?\s*$""")
|
||||
private val UNINSTALL = Regex("""(?i)\\CurrentVersion\\Uninstall\\[^\\]+$""")
|
||||
private val VALUE = Regex("""^"((?:[^"\\]|\\.)*)"=(?:str\(\d+\):)?"((?:[^"\\]|\\.)*)"\s*$""")
|
||||
private val UID = Regex("""--uid=([A-Za-z0-9_.-]+)""")
|
||||
|
||||
internal fun uninstallEntries(lines: Sequence<String>): List<Pair<String, String>> {
|
||||
val out = ArrayList<Pair<String, String>>()
|
||||
var inside = false
|
||||
var uid: String? = null
|
||||
var location: String? = null
|
||||
fun flush() {
|
||||
val u = uid
|
||||
val l = location
|
||||
if (u != null && l != null) out.add(u to l)
|
||||
uid = null
|
||||
location = null
|
||||
}
|
||||
for (line in lines) {
|
||||
if (line.startsWith("[")) {
|
||||
flush()
|
||||
inside = KEY.matchEntire(line)?.groupValues?.get(1)?.let { UNINSTALL.containsMatchIn(unescape(it)) } == true
|
||||
continue
|
||||
}
|
||||
if (!inside) continue
|
||||
val value = VALUE.matchEntire(line) ?: continue
|
||||
val text = unescape(value.groupValues[2])
|
||||
when (unescape(value.groupValues[1]).lowercase()) {
|
||||
"uninstallstring" -> if (text.contains("battle.net", ignoreCase = true)) uid = UID.find(text)?.groupValues?.get(1)
|
||||
"installlocation" -> location = text
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun unescape(s: String): String {
|
||||
val out = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i++]
|
||||
if (c != '\\' || i >= s.length) { out.append(c); continue }
|
||||
when (val e = s[i++]) {
|
||||
'n' -> out.append('\n')
|
||||
'r' -> out.append('\r')
|
||||
't' -> out.append('\t')
|
||||
'0' -> out.append('\u0000')
|
||||
'x' -> {
|
||||
var end = i
|
||||
while (end < s.length && end - i < 4 && s[end].lowercaseChar() in "0123456789abcdef") end++
|
||||
if (end > i) { out.append(s.substring(i, end).toInt(16).toChar()); i = end } else out.append('x')
|
||||
}
|
||||
else -> out.append(e)
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
internal fun productDbEntries(bytes: ByteArray): List<Pair<String, String>> {
|
||||
val installs = runCatching { fields(bytes, 0, bytes.size) }.getOrDefault(emptyList()).filter { it.first == 1 }
|
||||
return installs.mapNotNull { (_, start, end) ->
|
||||
runCatching {
|
||||
val install = fields(bytes, start, end)
|
||||
val uid = install.firstOrNull { it.first == 1 }?.let { String(bytes, it.second, it.third - it.second) }
|
||||
val path = install.firstOrNull { it.first == 3 }?.let { s -> fields(bytes, s.second, s.third).firstOrNull { it.first == 1 } }
|
||||
?.let { String(bytes, it.second, it.third - it.second) }
|
||||
val installed = install.firstOrNull { it.first == 4 }?.let { s -> fields(bytes, s.second, s.third).firstOrNull { it.first == 1 } }
|
||||
?.let { s -> varints(bytes, s.second, s.third)[1] }
|
||||
if (uid.isNullOrEmpty() || path.isNullOrEmpty() || installed == 0L) null else uid to path
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fields(bytes: ByteArray, from: Int, to: Int): List<Triple<Int, Int, Int>> = walk(bytes, from, to) { _, _ -> }
|
||||
|
||||
private fun varints(bytes: ByteArray, from: Int, to: Int): Map<Int, Long> =
|
||||
HashMap<Int, Long>().also { found -> walk(bytes, from, to) { field, value -> found.putIfAbsent(field, value) } }
|
||||
|
||||
private fun walk(bytes: ByteArray, from: Int, to: Int, varint: (Int, Long) -> Unit): List<Triple<Int, Int, Int>> {
|
||||
val out = ArrayList<Triple<Int, Int, Int>>()
|
||||
var i = from
|
||||
fun next(): Long {
|
||||
var value = 0L
|
||||
var shift = 0
|
||||
while (true) {
|
||||
require(i < to && shift < 64) { "truncated" }
|
||||
val b = bytes[i++].toInt() and 0xff
|
||||
value = value or ((b and 0x7f).toLong() shl shift)
|
||||
if (b < 0x80) return value
|
||||
shift += 7
|
||||
}
|
||||
}
|
||||
while (i < to) {
|
||||
val key = next()
|
||||
when ((key and 7).toInt()) {
|
||||
0 -> varint((key ushr 3).toInt(), next())
|
||||
1 -> { require(to - i >= 8) { "truncated" }; i += 8 }
|
||||
2 -> {
|
||||
val n = next()
|
||||
require(n in 0..(to - i).toLong()) { "truncated" }
|
||||
out.add(Triple((key ushr 3).toInt(), i, i + n.toInt()))
|
||||
i += n.toInt()
|
||||
}
|
||||
5 -> { require(to - i >= 4) { "truncated" }; i += 4 }
|
||||
else -> error("wire type ${key and 7}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun shortcut(context: Context, game: Game): com.droiddeck.launcher.frontend.AddedGames.Game? {
|
||||
val added = com.droiddeck.launcher.frontend.AddedGames
|
||||
val client = client(context)
|
||||
val guestExe = added.guestPath(context, client) ?: return null
|
||||
val guestDir = added.guestPath(context, client.parentFile ?: return null) ?: return null
|
||||
val guestPrefix = added.guestPath(context, prefix(context)) ?: return null
|
||||
val crc = java.util.zip.CRC32().apply { update("\"$guestExe\"${game.name}".toByteArray()) }.value
|
||||
val appId = com.droiddeck.launcher.session.SessionPrefs.addedGameAppId(context, game.folder.path, crc or 0x80000000L)
|
||||
val exe = added.candidates(game.folder).firstOrNull() ?: client
|
||||
return com.droiddeck.launcher.frontend.AddedGames.Game(
|
||||
game.folder, game.name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, listOf(exe),
|
||||
source = com.droiddeck.launcher.stores.Store.BATTLENET.id, storeId = game.uid,
|
||||
launchOptions = "STEAM_COMPAT_DATA_PATH=$guestPrefix %command% --exec=\"launch ${game.code}\"",
|
||||
)
|
||||
}
|
||||
|
||||
fun remove(context: Context) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
com.droiddeck.launcher.core.FileUtils.delete(prefix(context))
|
||||
File(root, "usr/local/share/applications/droiddeck-battlenet.desktop").delete()
|
||||
com.droiddeck.launcher.core.FileUtils.delete(File(root, "root/.cache/droiddeck/battlenet"))
|
||||
ComponentsManager.clearGameComponents(context, APP_ID.toString())
|
||||
Log.i(TAG, "removed")
|
||||
}
|
||||
|
||||
private val D2RFIX = Regex("""(?i)FEXCore-(\d+(?:\.\d+)*)-d2rfix(\d+)-linux\.wcp""")
|
||||
|
||||
private const val LISTING_MAX_AGE = 6 * 3600L
|
||||
|
||||
private val NEWEST = Comparator<Pair<List<Long>, Long>> { a, b ->
|
||||
for (i in 0 until minOf(a.first.size, b.first.size)) if (a.first[i] != b.first[i]) return@Comparator a.first[i].compareTo(b.first[i])
|
||||
if (a.first.size != b.first.size) a.first.size.compareTo(b.first.size) else a.second.compareTo(b.second)
|
||||
}
|
||||
|
||||
internal fun d2rfix(file: String): Pair<List<Long>, Long>? {
|
||||
val m = D2RFIX.matchEntire(file) ?: return null
|
||||
val version = m.groupValues[1].split('.').map { it.toLongOrNull() ?: return null }
|
||||
return version to (m.groupValues[2].toLongOrNull() ?: return null)
|
||||
}
|
||||
|
||||
internal fun newestD2rFix(items: List<ComponentsManager.CatalogItem>): ComponentsManager.CatalogItem? =
|
||||
items.filter { it.comp == "fex" }.mapNotNull { item -> d2rfix(item.file)?.let { item to it } }
|
||||
.maxWithOrNull { a, b -> NEWEST.compare(a.second, b.second) }?.first
|
||||
|
||||
fun ensureFex(context: Context): String = ensureFex(context) { ComponentsManager.catalog(it, true).items }
|
||||
|
||||
@Synchronized
|
||||
internal fun ensureFex(context: Context, refresh: (Context) -> List<ComponentsManager.CatalogItem>): String {
|
||||
val item = newestD2rFix(listing(context, refresh)) ?: error("no d2rfix FEXCore build in the Nightlies listing")
|
||||
val file = ComponentsManager.safeName(item.file)
|
||||
if (ComponentsManager.packages(context).none { it.file == file }) {
|
||||
Log.i(TAG, "downloading ${item.file}")
|
||||
ComponentsManager.download(context, item) {}
|
||||
}
|
||||
val scope = APP_ID.toString()
|
||||
if (ComponentsManager.gameComponents(context, scope)["fex"] != file) {
|
||||
ComponentsManager.setGameComponent(context, scope, "fex", file)
|
||||
Log.i(TAG, "FEX for the Battle.net prefix: $file")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun listing(context: Context, refresh: (Context) -> List<ComponentsManager.CatalogItem>): List<ComponentsManager.CatalogItem> {
|
||||
val cached = ComponentsManager.catalog(context, false)
|
||||
val age = System.currentTimeMillis() / 1000 - cached.fetchedAt
|
||||
if (age in 0 until LISTING_MAX_AGE && newestD2rFix(cached.items) != null) return cached.items
|
||||
val fetched = runCatching { refresh(context) }
|
||||
.onFailure { Log.w(TAG, "Nightlies listing: ${it.message}") }.getOrNull().orEmpty()
|
||||
return if (newestD2rFix(fetched) != null) fetched else cached.items
|
||||
}
|
||||
|
||||
internal fun videoMemory(totalMb: Long): Int = (totalMb / 5 / 512 * 512).coerceIn(1536, 3584).toInt()
|
||||
|
||||
internal fun videoMemoryOptions(megabytes: Int): String =
|
||||
"dxgi.maxDeviceMemory = $megabytes; dxgi.maxSharedMemory = $megabytes; d3d9.maxAvailableMemory = $megabytes"
|
||||
|
||||
fun ensureVideoMemory(context: Context): Boolean {
|
||||
val memory = ActivityManager.MemoryInfo()
|
||||
context.getSystemService(ActivityManager::class.java)?.getMemoryInfo(memory) ?: return false
|
||||
if (memory.totalMem <= 0) return false
|
||||
val options = videoMemoryOptions(videoMemory(memory.totalMem / (1024 * 1024)))
|
||||
val set = GameEnvironmentStore.setDefault(context, APP_ID.toString(), DXVK_CONFIG, options)
|
||||
if (set) Log.i(TAG, "DXVK_CONFIG for the Battle.net profile: $options")
|
||||
return set
|
||||
}
|
||||
|
||||
private const val DXVK_CONFIG = "DXVK_CONFIG"
|
||||
|
||||
fun ensureNoSteam(context: Context): Boolean {
|
||||
if (com.droiddeck.launcher.session.SessionPrefs.battleNetNoSteamSet(context)) return false
|
||||
GameEnvironmentStore.setGameNoSteam(context, APP_ID.toString(), true)
|
||||
com.droiddeck.launcher.session.SessionPrefs.setBattleNetNoSteamSet(context)
|
||||
Log.i(TAG, "Start without Steam for the Battle.net profile")
|
||||
return true
|
||||
}
|
||||
|
||||
fun answerFexRequest(context: Context, dir: File) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
runCatching { ensureVideoMemory(app) }.onFailure { Log.w(TAG, "video memory cap for the Battle.net profile", it) }
|
||||
runCatching { ensureNoSteam(app) }.onFailure { Log.w(TAG, "Start without Steam for the Battle.net profile", it) }
|
||||
val answer = try {
|
||||
"ok " + ensureFex(app)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "FEX for the Battle.net prefix", e)
|
||||
"error " + (e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
Log.i(TAG, "FEX request: $answer")
|
||||
val result = File(dir, FEX_RESULT)
|
||||
val partial = File(dir, "$FEX_RESULT.tmp")
|
||||
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
|
||||
}, "battlenet-fex").start()
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,7 @@ object BwrapSpawner {
|
||||
val binds = ArrayList<Bind>()
|
||||
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
|
||||
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
|
||||
File(rootfs, NET_LINK).takeIf { it.isFile }?.let { binds.add(Bind(it.path, "/$NET_LINK")) }
|
||||
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
|
||||
binds.addAll(bindsFor(session, rootfs, dev, dev))
|
||||
}
|
||||
@@ -227,6 +228,7 @@ object BwrapSpawner {
|
||||
}
|
||||
|
||||
private const val PRELOAD_DIR = "/run/droiddeck-preload"
|
||||
private const val NET_LINK = "etc/droiddeck-net"
|
||||
|
||||
private const val ZYPAK_SHIM = "#!/bin/sh\nprogram=\$1\nshift\nexec \"\$program\" --no-sandbox \"\$@\"\n"
|
||||
|
||||
|
||||
@@ -2,16 +2,19 @@ package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.session.OrphanReaper
|
||||
import com.droiddeck.launcher.session.SessionFiles
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object GuestCommand {
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
|
||||
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, cancelled: AtomicBoolean? = null,
|
||||
onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
@@ -19,14 +22,14 @@ object GuestCommand {
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath, cancelled) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
|
||||
fastPath: Boolean, onLine: (String) -> Unit): Int {
|
||||
fastPath: Boolean, cancelled: AtomicBoolean?, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
@@ -45,6 +48,15 @@ object GuestCommand {
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
// The Steam client installer and the package tools inside the guest fetch over plain curl,
|
||||
// so the app proxy has to reach them through the environment proot starts them with.
|
||||
DownloadSource.proxyHostPort(context).takeIf { it.isNotEmpty() }?.let { hostPort ->
|
||||
cmd += listOf(
|
||||
"http_proxy=http://$hostPort", "https_proxy=http://$hostPort",
|
||||
"HTTP_PROXY=http://$hostPort", "HTTPS_PROXY=http://$hostPort",
|
||||
"no_proxy=localhost,127.0.0.1,::1", "NO_PROXY=localhost,127.0.0.1,::1",
|
||||
)
|
||||
}
|
||||
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
@@ -56,13 +68,32 @@ object GuestCommand {
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
if (cancelled?.get() == true) return 130
|
||||
val process = builder.start()
|
||||
val pid = HostProcess.pidOf(process)
|
||||
OrphanReaper.keep(pid)
|
||||
val watcher = cancelled?.let { flag ->
|
||||
Thread({
|
||||
try {
|
||||
while (process.isAlive && !flag.get()) Thread.sleep(200)
|
||||
if (flag.get()) {
|
||||
// PRoot ignores SIGTERM; SIGQUIT makes it kill its tracees and exit cleanly.
|
||||
if (pid > 0) {
|
||||
runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) }
|
||||
} else {
|
||||
process.destroy()
|
||||
}
|
||||
if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly()
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}, "guest-cancel").apply { isDaemon = true; start() }
|
||||
}
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
|
||||
return process.waitFor()
|
||||
} finally {
|
||||
watcher?.interrupt()
|
||||
OrphanReaper.release(pid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,15 +301,18 @@ class LinuxNetworkLinkComponent(
|
||||
}
|
||||
}
|
||||
|
||||
private fun seededMac(): String {
|
||||
val id = Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) ?: "bannerlator"
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray())
|
||||
val b0 = (bytes[0].toInt() and 0xfc) or 0x02
|
||||
return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt())
|
||||
.joinToString(":") { "%02x".format(it and 0xff) }
|
||||
}
|
||||
private fun seededMac(): String =
|
||||
seededMac { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) }
|
||||
|
||||
companion object {
|
||||
internal fun seededMac(readId: () -> String?): String {
|
||||
val id = runCatching(readId).getOrNull() ?: "bannerlator"
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray())
|
||||
val b0 = (bytes[0].toInt() and 0xfc) or 0x02
|
||||
return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt())
|
||||
.joinToString(":") { "%02x".format(it and 0xff) }
|
||||
}
|
||||
|
||||
private const val TAG = "LinuxNetworkLink"
|
||||
private const val LINK_FILE = "etc/droiddeck-net"
|
||||
private const val LINK_INDEX = 2
|
||||
|
||||
@@ -110,6 +110,11 @@ public final class LinuxRuntime {
|
||||
File root = rootDir(context);
|
||||
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
|
||||
if (sessionRoot != null) lastBinds = binds;
|
||||
return commandWithBinds(context, root, binds, guestCommand);
|
||||
}
|
||||
|
||||
public static List<String> commandWithBinds(Context context, File root, List<String> binds,
|
||||
List<String> guestCommand) {
|
||||
List<String> cmd = prootPrefix(context, root, "/root");
|
||||
for (String spec : binds) bind(cmd, spec);
|
||||
cmd.addAll(guestCommand);
|
||||
|
||||
@@ -299,27 +299,50 @@ public final class LinuxRuntimeInstaller {
|
||||
discard(staging, null);
|
||||
return false;
|
||||
}
|
||||
java.util.List<String> carryPaths = root.isDirectory()
|
||||
? RuntimeFileTree.existingUserPaths(root, staging) : java.util.Collections.emptyList();
|
||||
if (root.isDirectory() && !root.renameTo(old)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
}
|
||||
File carryMarker = new File(staging, CARRY_MARKER);
|
||||
java.util.List<String> encodedPaths = new java.util.ArrayList<>();
|
||||
for (String rel : carryPaths) encodedPaths.add(java.net.URLEncoder.encode(rel, "UTF-8"));
|
||||
if (!FileUtils.writeString(carryMarker, String.join("\n", encodedPaths))) {
|
||||
if (old.isDirectory() && !old.renameTo(root))
|
||||
Log.w(TAG, "could not restore the previous runtime; retained at " + old);
|
||||
discard(staging, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
java.util.List<String> carried;
|
||||
try {
|
||||
RuntimeFileTree.carryHome(old, staging);
|
||||
carried = RuntimeFileTree.carryUserData(old, staging, false, carryPaths);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "could not carry root across the update; rolling back", e);
|
||||
old.renameTo(root);
|
||||
try { RuntimeFileTree.delete(staging, null); }
|
||||
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
|
||||
Log.w(TAG, "could not carry user data across the update; rolling back", e);
|
||||
if (old.isDirectory() && !old.renameTo(root))
|
||||
Log.w(TAG, "could not restore the previous runtime; retained at " + old);
|
||||
if (e.getSuppressed().length == 0) {
|
||||
new File(staging, CARRY_MARKER).delete();
|
||||
try { RuntimeFileTree.delete(staging, null); }
|
||||
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
|
||||
} else {
|
||||
Log.w(TAG, "incomplete rollback; retaining user data in " + staging);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!staging.renameTo(root)) {
|
||||
File keptTo = new File(staging, USER_DATA);
|
||||
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
|
||||
if (old.isDirectory()) old.renameTo(root);
|
||||
if (old.isDirectory()) {
|
||||
try {
|
||||
RuntimeFileTree.carryBack(staging, old, carried);
|
||||
new File(staging, CARRY_MARKER).delete();
|
||||
} catch (IOException e) { Log.w(TAG, "could not move all user data back after a failed swap", e); }
|
||||
old.renameTo(root);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
new File(root, CARRY_MARKER).delete();
|
||||
discard(old, null);
|
||||
return LinuxRuntime.isInstalled(context);
|
||||
} catch (Exception e) {
|
||||
@@ -330,17 +353,41 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
static final String CARRY_MARKER = ".droiddeck-carry";
|
||||
|
||||
static void recoverInterruptedSwap(File root, File staging, File old) throws IOException {
|
||||
if (!root.isDirectory() && old.isDirectory()) {
|
||||
if (!old.renameTo(root)) throw new IOException("Cannot restore the previous runtime at " + old);
|
||||
Log.w(TAG, "restored the previous runtime after an interrupted update");
|
||||
}
|
||||
File marker = new File(staging, CARRY_MARKER);
|
||||
if (!RuntimeFileTree.exists(marker.toPath())) return;
|
||||
File target = root.isDirectory() ? root : old;
|
||||
String markerContent = FileUtils.readString(marker);
|
||||
if (markerContent == null) throw new IOException("Cannot read runtime recovery marker: " + marker);
|
||||
if (!markerContent.equals("1")) {
|
||||
java.util.List<String> intended = new java.util.ArrayList<>();
|
||||
for (String rel : markerContent.split("\\n"))
|
||||
if (!rel.isEmpty()) intended.add(java.net.URLDecoder.decode(rel, "UTF-8"));
|
||||
if (target.isDirectory()) RuntimeFileTree.recoverCarriedPaths(staging, target, intended);
|
||||
if (staging.isDirectory() && RuntimeFileTree.hasPaths(staging, intended))
|
||||
throw new IOException("User data still needs recovery from " + staging);
|
||||
marker.delete();
|
||||
return;
|
||||
}
|
||||
File stagedHome = new File(staging, USER_DATA);
|
||||
File home = new File(target, USER_DATA);
|
||||
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
|
||||
if (target.isDirectory() && stagedHome.isDirectory() && (!RuntimeFileTree.exists(home.toPath()) || isEmptyDir(home))) {
|
||||
RuntimeFileTree.carryHome(staging, target);
|
||||
Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
|
||||
}
|
||||
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
|
||||
Log.w(TAG, "restored the previous runtime after an interrupted update");
|
||||
if (target.isDirectory() && staging.isDirectory()) {
|
||||
java.util.List<String> rest = RuntimeFileTree.carryUserData(staging, target, true);
|
||||
if (!rest.isEmpty()) Log.w(TAG, "recovered " + rest + " from an interrupted update");
|
||||
}
|
||||
if (staging.isDirectory() && RuntimeFileTree.hasUserData(staging, target))
|
||||
throw new IOException("User data still needs recovery from " + staging);
|
||||
new File(staging, CARRY_MARKER).delete();
|
||||
}
|
||||
|
||||
static final String TRASH = ".runtime-trash";
|
||||
@@ -364,9 +411,10 @@ public final class LinuxRuntimeInstaller {
|
||||
Log.i(TAG, "some runtime leftovers still cannot be deleted: " + trash);
|
||||
}
|
||||
|
||||
private static boolean isEmptyDir(File dir) {
|
||||
String[] names = dir.list();
|
||||
return dir.isDirectory() && names != null && names.length == 0;
|
||||
private static boolean isEmptyDir(File dir) throws IOException {
|
||||
try (java.nio.file.DirectoryStream<java.nio.file.Path> entries = java.nio.file.Files.newDirectoryStream(dir.toPath())) {
|
||||
return !entries.iterator().hasNext();
|
||||
}
|
||||
}
|
||||
|
||||
public static Removal beginUninstall(Context context) {
|
||||
|
||||
@@ -74,6 +74,163 @@ public final class RuntimeFileTree {
|
||||
throw new IOException("Cannot make directory removable: " + dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths in a runtime tree that belong to the user: the home folder, imported AppImages and script apps, and catalog
|
||||
* AppImages with their menu entries and markers. Tar-kind catalog packages (the desktop) unpack over /usr and cannot
|
||||
* be carried.
|
||||
*/
|
||||
static java.util.List<String> userPaths(File tree) throws IOException {
|
||||
java.util.List<String> paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps"));
|
||||
for (String n : children(new File(tree, "usr/local/share/applications"))) {
|
||||
if (n.endsWith(".desktop") && (n.startsWith("droiddeck-app-") || n.startsWith("droiddeck-appimage-")))
|
||||
paths.add("usr/local/share/applications/" + n);
|
||||
}
|
||||
for (String n : children(new File(tree, "opt/appimages"))) {
|
||||
if (!n.endsWith(".AppImage")) continue;
|
||||
String id = n.substring(0, n.length() - ".AppImage".length());
|
||||
java.util.Collections.addAll(paths, "opt/appimages/" + n,
|
||||
"usr/share/applications/droiddeck-" + id + ".desktop", ".droiddeck-pkg-" + id);
|
||||
}
|
||||
for (String n : children(new File(tree, "usr/share/applications"))) {
|
||||
if (!n.startsWith("droiddeck-") || !n.endsWith(".desktop")) continue;
|
||||
String id = n.substring("droiddeck-".length(), n.length() - ".desktop".length());
|
||||
if (!exists(new File(tree, "opt/appimages/" + id + ".AppImage").toPath()))
|
||||
paths.add("usr/share/applications/" + n);
|
||||
}
|
||||
for (String n : children(tree)) {
|
||||
if (!n.startsWith(".droiddeck-pkg-")) continue;
|
||||
String id = n.substring(".droiddeck-pkg-".length());
|
||||
boolean hasMenu = exists(new File(tree, "usr/share/applications/droiddeck-" + id + ".desktop").toPath());
|
||||
if (hasMenu && !exists(new File(tree, "opt/appimages/" + id + ".AppImage").toPath()))
|
||||
paths.add(n);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
static java.util.List<String> existingUserPaths(File from, File to) throws IOException {
|
||||
java.util.List<String> paths = new java.util.ArrayList<>();
|
||||
for (String rel : userPaths(from, to)) if (exists(safePath(from, rel).toPath())) paths.add(rel);
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static java.util.List<String> children(File dir) throws IOException {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir.toPath())) {
|
||||
for (Path entry : entries) names.add(entry.getFileName().toString());
|
||||
} catch (NoSuchFileException e) {
|
||||
return names;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
static boolean exists(Path path) throws IOException {
|
||||
try { Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); return true; }
|
||||
catch (NoSuchFileException e) { return false; }
|
||||
}
|
||||
|
||||
private static java.util.Set<String> userPaths(File from, File to) throws IOException {
|
||||
java.util.Set<String> paths = new java.util.LinkedHashSet<>(userPaths(from));
|
||||
paths.addAll(userPaths(to));
|
||||
return paths;
|
||||
}
|
||||
|
||||
static boolean hasUserData(File from, File to) throws IOException {
|
||||
for (String rel : userPaths(from, to)) if (exists(new File(from, rel).toPath())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves each user path that exists in {@code from} into {@code to}, replacing what {@code to} ships there. With
|
||||
* {@code onlyMissing}, a path already present in {@code to} is left alone. On failure the paths already moved go back
|
||||
* before the exception is thrown. Rollback failures are suppressed on that exception; callers must retain the source
|
||||
* of any incomplete rollback.
|
||||
* Returns the moved paths.
|
||||
*/
|
||||
static java.util.List<String> carryUserData(File from, File to, boolean onlyMissing) throws IOException {
|
||||
return carryUserData(from, to, onlyMissing, new java.util.ArrayList<>(userPaths(from, to)));
|
||||
}
|
||||
|
||||
static java.util.List<String> carryUserData(File from, File to, boolean onlyMissing, java.util.List<String> paths) throws IOException {
|
||||
java.util.List<String> moved = new java.util.ArrayList<>();
|
||||
try {
|
||||
for (String rel : paths) {
|
||||
File src = safePath(from, rel);
|
||||
if (!exists(src.toPath())) continue;
|
||||
File dst = safePath(to, rel);
|
||||
if (onlyMissing && exists(dst.toPath())) continue;
|
||||
move(from, to, rel);
|
||||
moved.add(rel);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
for (int i = moved.size() - 1; i >= 0; i--) {
|
||||
try { move(to, from, moved.get(i)); }
|
||||
catch (IOException back) { e.addSuppressed(back); }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Puts the given paths back from {@code from} into {@code to}; failures are collected, not stopped on. */
|
||||
static void carryBack(File from, File to, java.util.List<String> paths) throws IOException {
|
||||
IOException failed = null;
|
||||
for (int i = paths.size() - 1; i >= 0; i--) {
|
||||
try { move(from, to, paths.get(i)); }
|
||||
catch (IOException e) { if (failed == null) failed = e; else failed.addSuppressed(e); }
|
||||
}
|
||||
if (failed != null) throw failed;
|
||||
}
|
||||
|
||||
private static void move(File from, File to, String rel) throws IOException {
|
||||
File src = safePath(from, rel);
|
||||
File dst = safePath(to, rel);
|
||||
File parent = src.getParentFile();
|
||||
if (parent != null) makeWritable(parent);
|
||||
java.util.List<File> made = new java.util.ArrayList<>();
|
||||
for (File d = dst.getParentFile(); d != null; d = d.getParentFile()) {
|
||||
made.add(d);
|
||||
if (d.equals(to)) break;
|
||||
}
|
||||
for (int i = made.size() - 1; i >= 0; i--) {
|
||||
File d = made.get(i);
|
||||
if (!d.isDirectory() && !d.mkdirs()) throw new IOException("Cannot create " + d);
|
||||
makeWritable(d);
|
||||
}
|
||||
delete(dst, null);
|
||||
if (!src.renameTo(dst)) throw new IOException("Cannot carry " + rel + " across the update");
|
||||
}
|
||||
|
||||
static void recoverCarriedPaths(File staging, File target, java.util.List<String> paths) throws IOException {
|
||||
for (String rel : paths) {
|
||||
File src = safePath(staging, rel);
|
||||
if (!exists(src.toPath())) continue;
|
||||
File dst = safePath(target, rel);
|
||||
if (!exists(dst.toPath())) move(staging, target, rel);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean hasPaths(File tree, java.util.List<String> paths) throws IOException {
|
||||
for (String rel : paths) if (exists(safePath(tree, rel).toPath())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static File safePath(File tree, String rel) throws IOException {
|
||||
Path relative = java.nio.file.Paths.get(rel);
|
||||
if (relative.isAbsolute() || relative.getNameCount() == 0) throw new IOException("Invalid runtime path: " + rel);
|
||||
for (Path part : relative) if (part.toString().equals("..")) throw new IOException("Invalid runtime path: " + rel);
|
||||
Path current = tree.toPath();
|
||||
for (int i = 0; i < relative.getNameCount() - 1; i++) {
|
||||
current = current.resolve(relative.getName(i));
|
||||
try {
|
||||
BasicFileAttributes attrs = Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attrs.isDirectory()) throw new IOException("Runtime path has a non-directory parent: " + current);
|
||||
} catch (NoSuchFileException ignored) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tree.toPath().resolve(relative).toFile();
|
||||
}
|
||||
|
||||
static void carryHome(File old, File staging) throws IOException {
|
||||
File home = new File(old, "root");
|
||||
if (!home.isDirectory()) return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.FrontEndActivity
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
class RuntimeInstallService : Service() {
|
||||
@@ -75,7 +75,7 @@ class RuntimeInstallService : Service() {
|
||||
enableVibration(false)
|
||||
})
|
||||
val open = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
Intent(this, FrontEndActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
|
||||
@@ -56,8 +56,9 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
val timeToEmptyS = if (discharging && chargeNowUah > 0 && abs(currentAvgUa) > 0) chargeNowUah * 3600L / abs(currentAvgUa) else 0L
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING
|
||||
val timeToFullS = if (charging && chargeFullUah > chargeNowUah && abs(currentAvgUa) > 0) (chargeFullUah - chargeNowUah) * 3600L / abs(currentAvgUa) else 0L
|
||||
// Android can keep reporting charging at 100%; Linux reports Full there, and Steam shows 99% until it does.
|
||||
val statusText = when (status) {
|
||||
BatteryManager.BATTERY_STATUS_CHARGING -> "Charging"
|
||||
BatteryManager.BATTERY_STATUS_CHARGING -> if (pct >= 100) "Full" else "Charging"
|
||||
BatteryManager.BATTERY_STATUS_DISCHARGING -> "Discharging"
|
||||
BatteryManager.BATTERY_STATUS_FULL -> "Full"
|
||||
BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "Not charging"
|
||||
|
||||
@@ -51,7 +51,7 @@ object BootMovie {
|
||||
SessionPrefs.setBootMovieApplyNext(context, true)
|
||||
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
|
||||
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
|
||||
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
|
||||
SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
|
||||
}
|
||||
|
||||
fun customName(context: Context): String? =
|
||||
@@ -167,10 +167,12 @@ object BootMovie {
|
||||
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
|
||||
*/
|
||||
fun syncFromSteam(context: Context): Boolean {
|
||||
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
|
||||
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
|
||||
SessionPrefs.setBootMovieSteamSeen(context, path)
|
||||
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
|
||||
val named = SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context)) ?: return false
|
||||
val seen = SessionPrefs.bootMovieSteamSeen(context)
|
||||
if (named == seen) return false
|
||||
SessionPrefs.setBootMovieSteamSeen(context, named)
|
||||
if (named == SessionFiles.BOOT_MOVIE_PATH || (named.isEmpty() && seen == null)) return false
|
||||
val path = named.ifEmpty { "/movies/steam_os_startup.webm" }
|
||||
val prefix = "/uioverrides/movies/"
|
||||
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
|
||||
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
|
||||
|
||||
@@ -79,8 +79,9 @@ object BootMovieLibrary {
|
||||
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
|
||||
}
|
||||
|
||||
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
|
||||
file.name !in STAGED && !file.name.startsWith(".")
|
||||
private fun isMovie(file: File) = isWebm(file) && file.name !in STAGED
|
||||
|
||||
private fun isWebm(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) && !file.name.startsWith(".")
|
||||
|
||||
private fun readMeta(context: Context): JSONObject =
|
||||
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
|
||||
@@ -136,7 +137,7 @@ object BootMovieLibrary {
|
||||
}
|
||||
val bought = HashMap<String, String>()
|
||||
for (root in steamRoots(context)) {
|
||||
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
|
||||
File(root, "steamui/movies").listFiles { f -> isWebm(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
|
||||
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
|
||||
val scan = pointShopMovies(root)
|
||||
bought.putAll(scan.titles)
|
||||
@@ -144,8 +145,8 @@ object BootMovieLibrary {
|
||||
}
|
||||
val seen = HashSet<String>()
|
||||
for ((id, file) in steam) {
|
||||
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
|
||||
val native = file.nameWithoutExtension.lowercase() in NATIVE
|
||||
if ((file.name in STAGED && !native) || !seen.add(file.canonicalPath)) continue
|
||||
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
|
||||
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
|
||||
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
|
||||
@@ -153,8 +154,11 @@ object BootMovieLibrary {
|
||||
return out
|
||||
}
|
||||
|
||||
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
|
||||
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
|
||||
/** The four startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
|
||||
private val NATIVE = mapOf(
|
||||
"deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup",
|
||||
"steam_os_startup" to "SteamOS startup",
|
||||
)
|
||||
|
||||
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
|
||||
|
||||
|
||||
@@ -721,13 +721,19 @@ object ComponentsManager {
|
||||
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
|
||||
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
|
||||
|
||||
private fun packagesInUse(state: JSONObject): Set<String> {
|
||||
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
|
||||
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
|
||||
|
||||
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
|
||||
val names = HashSet<String>()
|
||||
for (key in listOf("active", "queued")) {
|
||||
val byProton = state.optJSONObject(key) ?: continue
|
||||
for (pid in byProton.keys()) {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
for (comp in comps.keys()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
val profiles = state.optJSONObject("profiles")
|
||||
@@ -747,8 +753,8 @@ object ComponentsManager {
|
||||
|
||||
fun stored(context: Context): Stored = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val used = packagesInUse(state)
|
||||
val installed = protons(context).associateBy { it.id }
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
val used = packagesInUse(state, installed)
|
||||
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
|
||||
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
@@ -774,27 +780,135 @@ object ComponentsManager {
|
||||
true
|
||||
}
|
||||
|
||||
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val state = loadState(context)
|
||||
val queued = state.sub("queued")
|
||||
for (pid in queued.keys()) {
|
||||
val comps = queued.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
}
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
for (key in listOf("queued", "active")) {
|
||||
val byProton = state.sub(key)
|
||||
for (pid in byProton.keys()) {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
|
||||
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val profiles = state.sub("profiles")
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
|
||||
}
|
||||
}
|
||||
|
||||
fun carryOver(context: Context, fromDir: String, toDir: String) {
|
||||
synchronized(lock) {
|
||||
val target = protons(context).firstOrNull { it.id == safeName(toDir) } ?: return
|
||||
val state = loadState(context)
|
||||
val wanted = carried(state, safeName(fromDir)) { File(packagesDir(context), it).isFile }
|
||||
val active = state.sub("active").optJSONObject(target.id)
|
||||
val queued = state.sub("queued").sub(target.id)
|
||||
var changed = false
|
||||
wanted.forEach { (comp, entry) ->
|
||||
if (active?.has(comp) != true && !queued.has(comp)) {
|
||||
queued.put(comp, entry)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (queued.length() == 0) state.sub("queued").remove(target.id)
|
||||
if (changed) saveState(context, state)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun carried(state: JSONObject, from: String, present: (String) -> Boolean): Map<String, JSONObject> = buildMap {
|
||||
state.optJSONObject("active")?.optJSONObject(from)?.let { active ->
|
||||
active.keys().forEach { comp ->
|
||||
val entry = active.optJSONObject(comp) ?: return@forEach
|
||||
val file = entry.optString("file")
|
||||
if (comp in COMPONENTS && file.endsWith(".wcp") && present(file)) {
|
||||
put(comp, JSONObject().put("kind", "package").put("file", file).put("label", entry.optString("label")))
|
||||
}
|
||||
}
|
||||
}
|
||||
state.optJSONObject("queued")?.optJSONObject(from)?.let { queued ->
|
||||
queued.keys().forEach { comp ->
|
||||
val entry = queued.optJSONObject(comp) ?: return@forEach
|
||||
if (comp in COMPONENTS && entry.optString("kind") == "package" && present(entry.optString("file"))) put(comp, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun forgetProton(context: Context, dirName: String) {
|
||||
synchronized(lock) {
|
||||
val id = safeName(dirName)
|
||||
if (protons(context).any { it.id == id }) return
|
||||
val state = loadState(context)
|
||||
val selected = listOf("active", "queued").map { state.sub(it).remove(id) != null }.any { it }
|
||||
if (selected && runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "forget $id", it) }.isFailure) return
|
||||
File(originalsDir(context), id).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun storePackage(context: Context, part: File, dest: File) {
|
||||
try {
|
||||
if (dest.isFile && runCatching { Hashes.sha256(dest) }.getOrNull()?.equals(Hashes.sha256(part), true) == true) return
|
||||
if (dest.exists()) checkPackageUnused(context, loadState(context), dest.name)
|
||||
val store = File(launchDir(context), "store").canonicalFile
|
||||
val cacheKey = unpackedCacheKey(dest.name)
|
||||
val unpacked = File(store, cacheKey).canonicalFile
|
||||
check(unpacked.parentFile == store) { "Invalid unpacked component cache path for ${dest.name}" }
|
||||
val complete = File(unpacked, ".complete")
|
||||
if (complete.exists()) check(complete.delete()) { "Could not invalidate unpacked component ${dest.name}" }
|
||||
if (!part.renameTo(dest)) {
|
||||
dest.delete()
|
||||
check(part.renameTo(dest)) { "Could not store ${dest.name}" }
|
||||
}
|
||||
if (!unpacked.deleteRecursively() && unpacked.exists()) {
|
||||
check(!complete.exists()) { "Could not invalidate unpacked component ${dest.name}" }
|
||||
Log.w(TAG, "Could not remove stale unpacked files for ${dest.name}; cache marker is invalidated")
|
||||
}
|
||||
} finally {
|
||||
part.delete()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun unpackedCacheKey(filename: String): String = safeName(filename.removeSuffix(".wcp")).also {
|
||||
require(it != "." && it != "..") { "Invalid component cache name" }
|
||||
}
|
||||
|
||||
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val state = loadState(context)
|
||||
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
|
||||
var changed = false
|
||||
for (key in listOf("queued", "active")) {
|
||||
val byProton = state.sub(key)
|
||||
for (pid in byProton.keys().asSequence().toList()) {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val entry = comps.optJSONObject(comp) ?: continue
|
||||
if (entry.optString("file") != name) continue
|
||||
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
|
||||
comps.remove(comp)
|
||||
changed = true
|
||||
}
|
||||
if (comps.length() == 0) byProton.remove(pid)
|
||||
}
|
||||
}
|
||||
val f = File(packagesDir(context), name)
|
||||
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
|
||||
f.delete()
|
||||
val profiles = state.sub("profiles")
|
||||
for (scope in profiles.keys().asSequence().toList()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
for (comp in COMPONENTS.filter { profile.optString(it) == name }) {
|
||||
rememberProfiledComponent(state, comp)
|
||||
profile.remove(comp)
|
||||
changed = true
|
||||
}
|
||||
if (profile.length() == 0) profiles.remove(scope)
|
||||
}
|
||||
check(f.delete()) { name }
|
||||
if (changed) saveState(context, state)
|
||||
context.getString(R.string.cmgr_deleted, name)
|
||||
}
|
||||
|
||||
@@ -811,7 +925,7 @@ object ComponentsManager {
|
||||
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
|
||||
val info = packageInfo(context, source)
|
||||
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
|
||||
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
|
||||
storePackage(context, source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true), dest)
|
||||
info.copy(file = dest.name)
|
||||
}
|
||||
|
||||
@@ -834,6 +948,7 @@ object ComponentsManager {
|
||||
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
|
||||
}
|
||||
}
|
||||
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
|
||||
val tmp = File(cache.parentFile, cache.name + ".tmp")
|
||||
tmp.writeText(out.toString()); tmp.renameTo(cache)
|
||||
@@ -963,7 +1078,7 @@ object ComponentsManager {
|
||||
}
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
|
||||
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
|
||||
synchronized(lock) { part.renameTo(dest) }
|
||||
synchronized(lock) { storePackage(context, part, dest) }
|
||||
return info.copy(file = dest.name)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,14 @@ object GameEnvironmentStore {
|
||||
return config
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setDefault(context: Context, scope: String, name: String, value: String): Boolean {
|
||||
val config = read(context)
|
||||
if (GameEnvironment.hasOverride(config, scope, name)) return false
|
||||
save(context, GameEnvironment.withScopeValue(config, scope, name, value))
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
|
||||
|
||||
@@ -14,9 +14,12 @@ object GameStorage {
|
||||
|
||||
fun options(context: Context): List<Option> {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
val primary = context.getExternalFilesDir(null)?.absolutePath
|
||||
val primaryStorage = Environment.getExternalStorageDirectory()?.absolutePath
|
||||
return context.getExternalFilesDirs(null).orEmpty().filterNotNull().mapNotNull { dir ->
|
||||
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
|
||||
if (!removable) return@mapNotNull null
|
||||
// Non-primary app dirs are usable even when Android reports the volume as non-removable (USB SSDs often do).
|
||||
if (primary != null && dir.absolutePath == primary) return@mapNotNull null
|
||||
if (!primaryStorage.isNullOrBlank() && dir.absolutePath.startsWith("$primaryStorage/")) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
|
||||
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
|
||||
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
|
||||
|
||||
@@ -26,6 +26,7 @@ object GpuClockPin {
|
||||
pinned = true
|
||||
"gpu clock: held at its top"
|
||||
} catch (t: Throwable) {
|
||||
File(context.filesDir, MARKER).delete()
|
||||
Log.w(TAG, "pin", t)
|
||||
"gpu clock: pin failed"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
@@ -23,11 +24,16 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
|
||||
}
|
||||
|
||||
fun prepare(): Boolean {
|
||||
if (!File(KGSL).exists() || MANGOHUD_FILES.none { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }) return false
|
||||
if (!File(KGSL).exists()) return false
|
||||
val unreadable = MANGOHUD_FILES.any { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }
|
||||
if (!unreadable && !File(GPUBUSY).canRead()) return false
|
||||
return try {
|
||||
dir.mkdirs()
|
||||
dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) }
|
||||
feeds.clear()
|
||||
PASSTHROUGH_FILES.forEach { name ->
|
||||
runCatching { File(dir, name).writeText(File(KGSL, name).readText()) }
|
||||
}
|
||||
loadFeed()?.let { load ->
|
||||
feeds.add(load)
|
||||
feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true))
|
||||
@@ -37,7 +43,7 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
|
||||
feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n"))
|
||||
}
|
||||
pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) }
|
||||
feeds.forEach { feed -> feed.read()?.let { write(feed, it) } }
|
||||
refresh()
|
||||
Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name ->
|
||||
"$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none")
|
||||
})
|
||||
@@ -55,11 +61,20 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
|
||||
while (running) {
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
if (!running) break
|
||||
for (feed in feeds) feed.read()?.let { write(feed, it) }
|
||||
refresh()
|
||||
}
|
||||
}, "gpu-stats").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
// kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read.
|
||||
private fun refresh() {
|
||||
val values = HashMap<() -> Long?, Long?>()
|
||||
for (feed in feeds) {
|
||||
if (feed.read !in values) values[feed.read] = feed.read()
|
||||
values[feed.read]?.let { write(feed, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
@@ -67,16 +82,30 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
|
||||
}
|
||||
|
||||
private fun loadFeed(): Feed? {
|
||||
pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }?.let { return it }
|
||||
val gpubusy = "$KGSL/gpubusy"
|
||||
val read = {
|
||||
runCatching {
|
||||
val parts = File(gpubusy).readText().trim().split(Regex("\\s+"))
|
||||
val total = parts[1].toLong()
|
||||
if (total > 0) (parts[0].toLong() * 100 / total).coerceIn(0, 100) else 0L
|
||||
latchedBusyFeed()?.let { return it }
|
||||
return pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }
|
||||
}
|
||||
|
||||
// kgsl publishes busy/total once per ~1 s of GPU-on time and zeroes it on any read while the GPU is
|
||||
// power-collapsed, so direct pollers mostly see 0; latch each published window instead.
|
||||
private fun latchedBusyFeed(): Feed? {
|
||||
var load = 0L
|
||||
var windowAt = 0L
|
||||
val read = read@{
|
||||
val parts = runCatching {
|
||||
File(GPUBUSY).readText().trim().split(Regex("\\s+")).map { it.toLong() }
|
||||
}.getOrNull()
|
||||
if (parts == null || parts.size < 2) return@read null
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (parts[1] > 0) {
|
||||
load = (parts[0] * 100 / parts[1]).coerceIn(0, 100)
|
||||
windowAt = now
|
||||
} else if (now - windowAt > LOAD_HOLD_MS) {
|
||||
load = 0
|
||||
}
|
||||
load
|
||||
}
|
||||
return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null
|
||||
return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), GPUBUSY, read, "%3d %%\n") else null
|
||||
}
|
||||
|
||||
private fun pick(name: String, sources: List<String>, format: String, unit: (Long) -> Long?): Feed? {
|
||||
@@ -100,8 +129,11 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
|
||||
const val KGSL = "/sys/class/kgsl/kgsl-3d0"
|
||||
const val MSM_LOAD = "/sys/kernel/debug/dri/0/perf_now"
|
||||
const val MSM_CLOCK = "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"
|
||||
private const val GPUBUSY = "$KGSL/gpubusy"
|
||||
private const val PERIOD_MS = 500L
|
||||
private const val LOAD_HOLD_MS = 5_000L
|
||||
private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp")
|
||||
private val PASSTHROUGH_FILES = listOf("gpu_model", "gpu_chipid")
|
||||
private const val QCOM_GPU = "/sys/kernel/gpu"
|
||||
private val LOAD_SOURCES = listOf(
|
||||
"$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy",
|
||||
|
||||
@@ -87,11 +87,10 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
if (line.startsWith("== STEP ")) {
|
||||
stepAt = at
|
||||
stepText = line.substringAfter("== STEP ").substringAfter(' ')
|
||||
val m = INSTALL_COUNT.find(line)
|
||||
if (m != null) {
|
||||
clientDownload(line)?.let { (what, percent) ->
|
||||
clientDownloadAt = at
|
||||
clientDownload = m.groupValues[1]
|
||||
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
|
||||
clientDownload = what
|
||||
clientPercent = percent
|
||||
}
|
||||
} else {
|
||||
val m = UPDATE_PROGRESS.find(line)
|
||||
@@ -132,6 +131,19 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun clientDownload(line: String): Pair<String, Int>? {
|
||||
val count = INSTALL_COUNT.find(line) ?: return null
|
||||
val mb = INSTALL_MEGABYTES.find(line.substring(count.range.last + 1))
|
||||
val done = mb?.groupValues?.get(1)?.toLongOrNull()
|
||||
val total = mb?.groupValues?.get(2)?.toLongOrNull()
|
||||
if (done != null && total != null && total > 0) {
|
||||
return "$done / $total MB" to (done * 100 / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
val part = count.groupValues[2].toIntOrNull() ?: return null
|
||||
val all = count.groupValues[3].toIntOrNull() ?: return null
|
||||
return count.groupValues[1] to (part.toLong() * 100 / maxOf(1, all)).coerceIn(0L, 100L).toInt()
|
||||
}
|
||||
|
||||
internal fun readableScriptStep(scriptStep: String): Boolean {
|
||||
val t = scriptStep.lowercase()
|
||||
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
|
||||
@@ -140,5 +152,6 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
private const val TAIL_BYTES = 48L * 1024
|
||||
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
|
||||
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
|
||||
private val INSTALL_MEGABYTES = Regex("""^, (\d+) of (\d+) MB""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
internal object MemoryReadout {
|
||||
data class Device(val totalKb: Long, val availableKb: Long, val swapTotalKb: Long, val swapFreeKb: Long) {
|
||||
val usedKb get() = (totalKb - availableKb).coerceAtLeast(0)
|
||||
val swapUsedKb get() = (swapTotalKb - swapFreeKb).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
data class Split(val gameKb: Long, val droiddeckKb: Long, val gpuKb: Long?)
|
||||
|
||||
fun parseMeminfo(text: String): Device? {
|
||||
fun field(name: String) = Regex("""(?m)^$name:\s+(\d+) kB""").find(text)?.groupValues?.get(1)?.toLongOrNull()
|
||||
val total = field("MemTotal") ?: return null
|
||||
val available = field("MemAvailable") ?: return null
|
||||
return Device(total, available, field("SwapTotal") ?: 0, field("SwapFree") ?: 0)
|
||||
}
|
||||
|
||||
fun residentKb(status: String): Long {
|
||||
fun field(name: String) = Regex("""(?m)^$name:\s+(\d+) kB""").find(status)?.groupValues?.get(1)?.toLongOrNull() ?: 0
|
||||
return field("RssAnon") + field("RssShmem")
|
||||
}
|
||||
|
||||
fun inWinePrefix(environ: ByteArray): Boolean {
|
||||
var start = 0
|
||||
val key = "WINEPREFIX=".toByteArray()
|
||||
for (i in 0..environ.size) {
|
||||
if (i == environ.size || environ[i] == 0.toByte()) {
|
||||
if (i - start >= key.size && (key.indices).all { environ[start + it] == key[it] }) return true
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun androidKb(device: Device, split: Split): Long =
|
||||
(device.usedKb - split.gameKb - split.droiddeckKb - (split.gpuKb ?: 0)).coerceAtLeast(0)
|
||||
|
||||
fun gb(kb: Long): String = String.format(Locale.US, "%.1f", kb / (1024.0 * 1024.0))
|
||||
|
||||
fun availableMb(): Long? = runCatching { parseMeminfo(File("/proc/meminfo").readText())?.availableKb?.div(1024) }.getOrNull()
|
||||
|
||||
fun device(): Device? = runCatching { parseMeminfo(File("/proc/meminfo").readText()) }.getOrNull()
|
||||
|
||||
fun split(root: Int = android.os.Process.myPid()): Split {
|
||||
val parents = HashMap<Int, Int>()
|
||||
File("/proc").listFiles()?.forEach { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@forEach
|
||||
parentOf(pid)?.let { parents[pid] = it }
|
||||
}
|
||||
val children = parents.entries.groupBy({ it.value }, { it.key })
|
||||
var game = 0L
|
||||
var droiddeck = 0L
|
||||
val queue = ArrayDeque<Int>().apply { add(root) }
|
||||
val seen = HashSet<Int>()
|
||||
while (queue.isNotEmpty()) {
|
||||
val pid = queue.removeFirst()
|
||||
if (!seen.add(pid)) continue
|
||||
val status = runCatching { File("/proc/$pid/status").readText() }.getOrNull()
|
||||
if (status != null) {
|
||||
val kb = residentKb(status)
|
||||
val windows = pid != root && runCatching { inWinePrefix(File("/proc/$pid/environ").readBytes()) }.getOrDefault(false)
|
||||
if (windows) game += kb else droiddeck += kb
|
||||
}
|
||||
children[pid]?.let(queue::addAll)
|
||||
}
|
||||
return Split(game, droiddeck, gpuKb())
|
||||
}
|
||||
|
||||
private fun parentOf(pid: Int): Int? {
|
||||
val stat = runCatching { File("/proc/$pid/stat").readText() }.getOrNull() ?: return null
|
||||
val close = stat.lastIndexOf(')')
|
||||
if (close < 0 || close + 2 >= stat.length) return null
|
||||
return stat.substring(close + 2).split(' ').getOrNull(1)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun gpuKb(): Long? = runCatching {
|
||||
File("/sys/class/kgsl/kgsl/page_alloc").readText().trim().split(Regex("\\s+"))[0].toLong() / 1024
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
object MemoryLog {
|
||||
private const val TAG = "Memory"
|
||||
private const val PERIOD_MS = 10_000L
|
||||
@Volatile private var thread: Thread? = null
|
||||
|
||||
internal fun line(device: MemoryReadout.Device, split: MemoryReadout.Split): String =
|
||||
"used ${device.usedKb / 1024}/${device.totalKb / 1024} MB, available ${device.availableKb / 1024} MB, " +
|
||||
"swap ${device.swapUsedKb / 1024}/${device.swapTotalKb / 1024} MB | game ${split.gameKb / 1024}, " +
|
||||
"droiddeck ${split.droiddeckKb / 1024}, android ${MemoryReadout.androidKb(device, split) / 1024}, " +
|
||||
"gpu ${split.gpuKb?.div(1024) ?: "?"} MB"
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (thread != null) return
|
||||
thread = Thread({
|
||||
while (!Thread.currentThread().isInterrupted) {
|
||||
runCatching { MemoryReadout.device()?.let { android.util.Log.i(TAG, line(it, MemoryReadout.split())) } }
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
}
|
||||
}, "memory-log").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryHud(context: Context) {
|
||||
var text by mutableStateOf("")
|
||||
private set
|
||||
|
||||
private val context = context.applicationContext
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@Volatile private var running = false
|
||||
@Volatile private var generation = 0
|
||||
private var thread: Thread? = null
|
||||
|
||||
fun refresh() {
|
||||
if (SessionPrefs.memoryHud(context) == SessionPrefs.MEMORY_HUD_OFF) stop() else start()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (running || SessionPrefs.memoryHud(context) == SessionPrefs.MEMORY_HUD_OFF) return
|
||||
running = true
|
||||
val token = ++generation
|
||||
thread = Thread({
|
||||
while (running && generation == token) {
|
||||
val line = runCatching { sample() }.getOrDefault("")
|
||||
handler.post { if (running && generation == token) text = line }
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
}
|
||||
}, "memory-hud").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
generation++
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
text = ""
|
||||
}
|
||||
|
||||
private fun sample(): String {
|
||||
val device = MemoryReadout.device() ?: return ""
|
||||
val memory = context.getString(
|
||||
R.string.memory_hud_simple,
|
||||
MemoryReadout.gb(device.usedKb), MemoryReadout.gb(device.totalKb),
|
||||
MemoryReadout.gb(device.swapUsedKb), MemoryReadout.gb(device.swapTotalKb),
|
||||
)
|
||||
if (SessionPrefs.memoryHud(context) != SessionPrefs.MEMORY_HUD_ADVANCED) return memory
|
||||
val split = MemoryReadout.split()
|
||||
val parts = context.getString(
|
||||
R.string.memory_hud_split,
|
||||
MemoryReadout.gb(split.gameKb), MemoryReadout.gb(split.droiddeckKb), MemoryReadout.gb(MemoryReadout.androidKb(device, split)),
|
||||
)
|
||||
val gpu = split.gpuKb?.let { context.getString(R.string.memory_hud_gpu, MemoryReadout.gb(it)) }
|
||||
return listOfNotNull(memory, parts, gpu).joinToString("\n")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PERIOD_MS = 2000L
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import com.droiddeck.launcher.stores.epic.EpicClient
|
||||
object NoSteamLaunch {
|
||||
const val PROTON_RUN = "/usr/local/bin/droiddeck-proton-run"
|
||||
const val EPIC = "/usr/local/bin/droiddeck-epic"
|
||||
const val BATTLENET = com.droiddeck.launcher.runtime.BattleNet.PROGRAM
|
||||
|
||||
class Command(val program: String, val args: List<String>, val name: String)
|
||||
|
||||
fun scope(game: Library.SteamGame): String? = when {
|
||||
game.library == Library.EPIC_LAUNCHER -> EpicClient.shortcutAppId.toString()
|
||||
game.library == Library.BATTLENET_LAUNCHER -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
|
||||
game.library == Library.ADDED || game.appId > 0 -> game.profileKey
|
||||
else -> null
|
||||
}?.takeIf { it.isNotEmpty() && GameEnvironment.validScope(it) }
|
||||
@@ -30,6 +32,8 @@ object NoSteamLaunch {
|
||||
|
||||
fun command(game: Library.SteamGame, added: AddedGames.Game?): Command? = when {
|
||||
game.library == Library.EPIC_LAUNCHER -> Command(EPIC, listOf("run"), game.name)
|
||||
game.library == Library.ADDED && game.source == com.droiddeck.launcher.stores.Store.BATTLENET.id ->
|
||||
game.storeId?.let(com.droiddeck.launcher.runtime.BattleNet::title)?.let { Command(BATTLENET, listOf("launch", it.code), game.name) }
|
||||
game.library == Library.ADDED -> added?.let { a -> a.steamAppId?.let { Command(PROTON_RUN, listOf("--app", it.toString()), a.name) } ?: addedCommand(a) }
|
||||
game.appId > 0 -> Command(PROTON_RUN, listOf("--app", game.appId.toString()), game.name)
|
||||
else -> null
|
||||
@@ -37,7 +41,9 @@ object NoSteamLaunch {
|
||||
|
||||
fun addedCommand(game: AddedGames.Game): Command = Command(
|
||||
PROTON_RUN,
|
||||
listOf("--app", (game.appId and 0xFFFFFFFFL).toString(), "--dir", game.guestDir, "--options", game.launchOptions, "--", game.guestExe),
|
||||
listOf("--app", (game.appId and 0xFFFFFFFFL).toString(), "--dir", game.guestDir) +
|
||||
(if (game.launchOptions.isEmpty()) emptyList() else listOf("--options", game.launchOptions)) +
|
||||
listOf("--", game.guestExe),
|
||||
game.name,
|
||||
)
|
||||
|
||||
@@ -50,6 +56,7 @@ object NoSteamLaunch {
|
||||
|
||||
fun nameFor(program: String?, args: List<String>): String? = when (program) {
|
||||
EPIC -> Library.EPIC_LAUNCHER_NAME
|
||||
BATTLENET -> args.getOrNull(args.indexOf("launch") + 1)?.takeIf { args.contains("launch") }?.let { names[it] } ?: com.droiddeck.launcher.stores.Store.BATTLENET.label
|
||||
PROTON_RUN -> args.getOrNull(args.indexOf("--app") + 1)?.takeIf { args.contains("--app") }?.let { names[it] }
|
||||
else -> null
|
||||
}
|
||||
@@ -58,5 +65,6 @@ object NoSteamLaunch {
|
||||
|
||||
fun remember(command: Command) {
|
||||
if (command.program == PROTON_RUN) command.args.getOrNull(command.args.indexOf("--app") + 1)?.let { names[it] = command.name }
|
||||
if (command.program == BATTLENET) command.args.getOrNull(command.args.indexOf("launch") + 1)?.takeIf { command.args.contains("launch") }?.let { names[it] = command.name }
|
||||
}
|
||||
}
|
||||
@@ -114,12 +114,19 @@ object ProtonDefault {
|
||||
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
|
||||
}
|
||||
|
||||
internal fun movedGames(choices: Map<String, GameChoice?>, from: Set<String>, to: String): Map<String, GameChoice?>? {
|
||||
if (choices.values.none { it != null && !it.valve && it.dir in from }) return null
|
||||
return choices.mapValues { (_, choice) -> if (choice != null && !choice.valve && choice.dir in from) GameChoice(false, to) else choice }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
fun moveGames(context: Context, from: Set<String>, to: String) {
|
||||
val d = dir(context)
|
||||
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
|
||||
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
|
||||
val moved = movedGames(parseGames(read(File(d, GAMES))), from, to) ?: return
|
||||
writeGames(d, moved)
|
||||
}
|
||||
|
||||
private fun writeGames(d: File, choices: Map<String, GameChoice?>) {
|
||||
val games = JSONObject()
|
||||
choices.forEach { (id, choice) ->
|
||||
games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL)
|
||||
@@ -134,6 +141,15 @@ object ProtonDefault {
|
||||
Log.w(TAG, "could not write the per-game Proton request", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val d = dir(context)
|
||||
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
|
||||
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
|
||||
writeGames(d, choices)
|
||||
val state = state(context)
|
||||
val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } }
|
||||
return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) {
|
||||
|
||||
@@ -16,16 +16,20 @@ object ProtonExtras {
|
||||
private const val TAG = "ProtonExtras"
|
||||
private const val NEED_BYTES = 4L * 1024 * 1024 * 1024
|
||||
private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15"
|
||||
const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000
|
||||
const val CHECK_EVERY_MS = 60L * 60 * 1000
|
||||
@Volatile
|
||||
var installInProgress = false
|
||||
private set
|
||||
@Volatile
|
||||
private var replacing = false
|
||||
|
||||
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
|
||||
|
||||
internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
|
||||
val sha256: String? = null)
|
||||
|
||||
const val OWN = "droiddeck"
|
||||
|
||||
val tools = listOf(
|
||||
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
|
||||
@@ -78,11 +82,23 @@ object ProtonExtras {
|
||||
|
||||
fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool))
|
||||
|
||||
fun missing(context: Context, tool: Tool): String? =
|
||||
if (tool.id == OWN && !declined(context) && installedAll(context, tool).isEmpty()) cachedLatest(context, tool) else null
|
||||
|
||||
private fun choices(context: Context) = context.getSharedPreferences("proton-auto-updates", Context.MODE_PRIVATE)
|
||||
|
||||
fun declined(context: Context): Boolean = choices(context).getBoolean("$OWN.declined", false)
|
||||
|
||||
fun setDeclined(context: Context, declined: Boolean) {
|
||||
choices(context).edit().putBoolean("$OWN.declined", declined).apply()
|
||||
}
|
||||
|
||||
fun cachedLatest(context: Context, tool: Tool): String? =
|
||||
readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null }
|
||||
|
||||
private val checkLock = Any()
|
||||
private const val RETRY_MS = 15L * 60 * 1000
|
||||
private const val USER_SETTINGS = "user_settings.py"
|
||||
private var lastAttempt = 0L
|
||||
|
||||
fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean {
|
||||
@@ -136,11 +152,51 @@ object ProtonExtras {
|
||||
|
||||
fun remove(context: Context, tool: Tool, dir: String? = null) {
|
||||
val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return
|
||||
File(toolsDir(context), name).deleteRecursively()
|
||||
if (File(toolsDir(context), name).deleteRecursively()) ComponentsManager.forgetProton(context, name)
|
||||
}
|
||||
|
||||
fun removeOlder(context: Context, tool: Tool) {
|
||||
installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() }
|
||||
installedAll(context, tool).dropLast(1).forEach {
|
||||
if (File(toolsDir(context), it).deleteRecursively()) ComponentsManager.forgetProton(context, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceOlder(context: Context, tool: Tool) {
|
||||
val all = installedAll(context, tool)
|
||||
val newest = all.lastOrNull() ?: return
|
||||
val older = all.dropLast(1)
|
||||
if (older.isEmpty()) return
|
||||
replacing = true
|
||||
try {
|
||||
for (old in older.asReversed()) {
|
||||
if (SessionState.running) break
|
||||
if (runCatching { ComponentsManager.carryOver(context, old, newest) }.onFailure { Log.w(TAG, "components of $old", it) }.isFailure) continue
|
||||
if (runCatching { keepUserSettings(context, old, newest) }.onFailure { Log.w(TAG, "user settings of $old", it) }.isFailure) continue
|
||||
if (runCatching { ProtonDefault.moveGames(context, setOf(old), newest) }.onFailure { Log.w(TAG, "per-game picks of $old", it) }.isFailure) continue
|
||||
if (File(toolsDir(context), old).deleteRecursively()) ComponentsManager.forgetProton(context, old)
|
||||
else Log.w(TAG, "could not remove $old")
|
||||
}
|
||||
} finally {
|
||||
replacing = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepUserSettings(context: Context, old: String, newest: String) {
|
||||
val from = File(File(toolsDir(context), old), USER_SETTINGS)
|
||||
val to = File(File(toolsDir(context), newest), USER_SETTINGS)
|
||||
if (from.isFile && !to.exists()) from.copyTo(to)
|
||||
}
|
||||
|
||||
fun tidy(context: Context) {
|
||||
val tool = tools.firstOrNull { it.id == OWN } ?: return
|
||||
if (installedAll(context, tool).size > 1) replaceOlder(context, tool)
|
||||
}
|
||||
|
||||
fun awaitReplacement(timeoutMs: Long) {
|
||||
val until = System.currentTimeMillis() + timeoutMs
|
||||
while (replacing && System.currentTimeMillis() < until) {
|
||||
try { Thread.sleep(50) } catch (_: InterruptedException) { Thread.currentThread().interrupt(); return }
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -244,6 +300,7 @@ object ProtonExtras {
|
||||
archive.delete()
|
||||
unqueue(context, tool)
|
||||
record(context, tool, placed, asset.tag)
|
||||
if (tool.id == OWN) replaceOlder(context, tool)
|
||||
if (EsyncPacks.enabled(context)) {
|
||||
onProgress(context.getString(R.string.esync_fetching), -1)
|
||||
try {
|
||||
|
||||
@@ -200,22 +200,86 @@ object SessionArtifacts {
|
||||
internal fun recordedClient(dir: File): String? =
|
||||
runCatching { File(dir, CLIENT_FILE).readText().trim() }.getOrNull()?.takeIf { it == "arm64" || it == "x86_64" }
|
||||
|
||||
private const val LOG_MARKS = "steam-log-marks.txt"
|
||||
private const val MAX_STEAM_LOG = 8L * 1024 * 1024
|
||||
private const val HEAD = 4096L
|
||||
|
||||
private class Mark(val size: Long, val head: String)
|
||||
|
||||
fun markSteamLogs(dir: File, logs: File) {
|
||||
val lines = logs.listFiles { f -> f.isFile }?.mapNotNull { f ->
|
||||
val size = f.length()
|
||||
head(f, size)?.let { "$size\t$it\t${f.name}" }
|
||||
} ?: return
|
||||
runCatching { File(dir, LOG_MARKS).writeText(lines.joinToString("") { "$it\n" }) }
|
||||
}
|
||||
|
||||
private fun head(f: File, length: Long): String? = runCatching {
|
||||
val buf = ByteArray(minOf(length, HEAD).toInt())
|
||||
f.inputStream().use { input ->
|
||||
var off = 0
|
||||
while (off < buf.size) {
|
||||
val n = input.read(buf, off, buf.size - off)
|
||||
if (n < 0) return@runCatching null
|
||||
off += n
|
||||
}
|
||||
}
|
||||
java.security.MessageDigest.getInstance("SHA-1").digest(buf).joinToString("") { "%02x".format(it) }
|
||||
}.getOrNull()
|
||||
|
||||
private fun steamLogMarks(dir: File): List<Mark>? = runCatching {
|
||||
File(dir, LOG_MARKS).readLines().mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 3)
|
||||
parts.getOrNull(0)?.toLongOrNull()?.let { size -> parts.getOrNull(1)?.let { Mark(size, it) } }
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun startOf(src: File, size: Long, marks: List<Mark>): Long {
|
||||
val heads = HashMap<Long, String?>()
|
||||
return marks.filter { it.size <= size }.sortedByDescending { it.size }
|
||||
.firstOrNull { m -> heads.getOrPut(minOf(m.size, HEAD)) { head(src, m.size) } == m.head }?.size ?: 0L
|
||||
}
|
||||
|
||||
private fun copySteamLogs(context: Context, dir: File, record: Record, steamArch: String? = null) {
|
||||
val logs = File(steamArch?.let { SessionPrefs.steamRoot(context, it) } ?: SessionPrefs.activeSteamRoot(context), "logs")
|
||||
if (!logs.isDirectory) return
|
||||
val out = File(dir, "steam").apply { mkdirs() }
|
||||
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
|
||||
copySessionLogs(logs, dir, out).forEach { dst -> record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) }
|
||||
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
|
||||
}
|
||||
|
||||
internal fun copySessionLogs(logs: File, dir: File, out: File): List<File> {
|
||||
val marks = steamLogMarks(dir)
|
||||
val copied = ArrayList<File>()
|
||||
logs.listFiles { f -> f.isFile }?.forEach { src ->
|
||||
val size = src.length()
|
||||
val from = marks?.let { startOf(src, size, it) } ?: 0L
|
||||
if (marks != null && from == size || size - from >= MAX_STEAM_LOG) return@forEach
|
||||
try {
|
||||
val dst = File(out, src.name)
|
||||
dst.bufferedWriter().use { w ->
|
||||
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
|
||||
src.inputStream().buffered().use { input ->
|
||||
var left = if (from > 0) from - 1 else 0L
|
||||
while (left > 0) {
|
||||
val skipped = input.skip(left)
|
||||
if (skipped <= 0) break
|
||||
left -= skipped
|
||||
}
|
||||
if (from > 0 && input.read() != '\n'.code) {
|
||||
while (true) {
|
||||
val c = input.read()
|
||||
if (c < 0 || c == '\n'.code) break
|
||||
}
|
||||
}
|
||||
dst.bufferedWriter().use { w ->
|
||||
input.bufferedReader().forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
|
||||
}
|
||||
}
|
||||
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
|
||||
copied.add(dst)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${src.name}", e)
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
|
||||
return copied
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
||||
@@ -79,5 +79,5 @@ object SessionDisplay {
|
||||
}
|
||||
|
||||
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
|
||||
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
|
||||
(size.second * 16 + 4) / 9 / 2 * 2 > size.first || (size.first * 9 + 8) / 16 / 2 * 2 > size.second
|
||||
}
|
||||
@@ -58,8 +58,10 @@ object SessionFiles {
|
||||
"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-shaders" to "usr/local/bin/droiddeck-steam-shaders",
|
||||
"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-steam-share" to "usr/local/bin/droiddeck-steam-share",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
@@ -72,12 +74,14 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
|
||||
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
|
||||
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
|
||||
"usr/local/bin/droiddeck-battlenet" to "usr/local/bin/droiddeck-battlenet",
|
||||
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
|
||||
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
|
||||
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
|
||||
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
|
||||
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
|
||||
"usr/local/bin/droiddeck-steam-x64" to "usr/local/bin/droiddeck-steam-x64",
|
||||
"usr/local/bin/droiddeck-steamrt-shim" to "usr/local/bin/droiddeck-steamrt-shim",
|
||||
"usr/local/bin/droiddeck-x64-arm-proton" to "usr/local/bin/droiddeck-x64-arm-proton",
|
||||
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
|
||||
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
|
||||
@@ -94,6 +98,15 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
"usr/local/bin/droiddeck-innoextract" to "usr/local/bin/droiddeck-innoextract",
|
||||
"usr/local/lib/droiddeck-inno/innoextract" to "usr/local/lib/droiddeck-inno/innoextract",
|
||||
"usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0",
|
||||
"usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0",
|
||||
"usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0",
|
||||
"usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0",
|
||||
"usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0",
|
||||
"usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract" to "usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract",
|
||||
"usr/local/share/licenses/droiddeck-inno/LICENSE.boost" to "usr/local/share/licenses/droiddeck-inno/LICENSE.boost",
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
"usr/bin/steamos-select-branch" to "usr/bin/steamos-select-branch",
|
||||
"usr/bin/steamos-session-select" to "usr/bin/steamos-session-select",
|
||||
@@ -138,6 +151,8 @@ object SessionFiles {
|
||||
} + listOf("libvulkan-thunk.so", "libwebkitinput.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",
|
||||
// The gamescope pack carries its own WSI layer (patch 0117); it replaces the runtime's stock copy.
|
||||
"usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so" to "usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
@@ -202,7 +217,11 @@ object SessionFiles {
|
||||
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
|
||||
preload.append("/usr/local/lib/libfakeinput.so\n")
|
||||
}
|
||||
if (ProotFastPath.enabled(context)) preload.append(ProotFastPath.LIBRARY + "\n")
|
||||
if (ProotFastPath.enabled(context)) {
|
||||
val library = com.droiddeck.launcher.agent.AgentEnv.fastPathLibrary(context)
|
||||
preload.append(library + "\n")
|
||||
if (startsSession && library != ProotFastPath.LIBRARY) Log.i(TAG, "fast path preload: $library (debugging override)")
|
||||
}
|
||||
val etc = File(root, "etc").apply { mkdirs() }
|
||||
val staged = File(etc, "ld.so.preload.staged")
|
||||
if (!FileUtils.writeString(staged, preload.toString())
|
||||
@@ -388,10 +407,12 @@ object SessionFiles {
|
||||
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
|
||||
|
||||
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
|
||||
internal fun startupMoviePath(config: File): String? {
|
||||
internal fun startupMoviePath(config: File): String? = startupMovieLocalPath(config)?.takeIf { it.isNotEmpty() }
|
||||
|
||||
internal fun startupMovieLocalPath(config: File): String? {
|
||||
val text = runCatching { config.readText() }.getOrNull() ?: return null
|
||||
val movie = activeStartupMovie(text) ?: return null
|
||||
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
|
||||
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")
|
||||
}
|
||||
|
||||
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.FileProvider
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import com.droiddeck.launcher.runtime.BattleNet
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
@@ -49,7 +50,8 @@ object SessionLogShare {
|
||||
val logs = LinuxRuntime.logDir(context)
|
||||
val stores = recent(File(logs, STORES_DIR))
|
||||
val tools = recent(File(logs, SessionPaths.TOOLS_DIR))
|
||||
val total = (files + live + stores + tools).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
val battleNet = battleNetLogs(context)
|
||||
val total = (files + live + stores + tools + battleNet.map { it.second }).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
var done = 0L
|
||||
var shown = -1
|
||||
fun advance(f: File) {
|
||||
@@ -62,6 +64,7 @@ object SessionLogShare {
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
|
||||
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
|
||||
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
|
||||
battleNet.forEach { (name, f) -> addEntry(z, "$BATTLENET_DIR/$name", f); advance(f) }
|
||||
}
|
||||
return zip
|
||||
}
|
||||
@@ -88,6 +91,27 @@ object SessionLogShare {
|
||||
|
||||
private fun shareDir(context: Context): File = File(context.cacheDir, "share")
|
||||
|
||||
private const val BATTLENET_DIR = "battlenet"
|
||||
|
||||
private fun battleNetLogs(context: Context): List<Pair<String, File>> {
|
||||
val drive = File(BattleNet.prefix(context), "pfx/drive_c")
|
||||
val data = File(drive, "ProgramData/Battle.net")
|
||||
fun logsUnder(dir: File, prefix: String) = dir.listFiles { f -> f.isDirectory && f.name.startsWith(prefix) }.orEmpty().map { File(it, "Logs") }
|
||||
return newest("droiddeck", listOf(File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet")), 2, setOf("battlenet.log", "spawned.log")) +
|
||||
newest("client", listOf(File(drive, "users/steamuser/AppData/Local/Battle.net/Logs")), 6) +
|
||||
newest("agent", listOf(File(data, "Agent/Logs")) + logsUnder(File(data, "Agent"), "Agent."), 10, byDir = true) +
|
||||
newest("setup", logsUnder(File(data, "Setup"), ""), 3, byDir = true)
|
||||
}
|
||||
|
||||
private fun newest(folder: String, dirs: List<File>, count: Int, names: Set<String>? = null, byDir: Boolean = false): List<Pair<String, File>> {
|
||||
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
|
||||
return dirs.flatMap { dir ->
|
||||
dir.listFiles { f -> f.isFile && f.name.endsWith(".log") && (names == null || f.name in names) && f.lastModified() >= since && f.length() <= STEAM_LOG_MAX_BYTES }.orEmpty().toList()
|
||||
}.sortedByDescending { it.lastModified() }.take(count).map { f ->
|
||||
(if (byDir) "$folder/${f.parentFile?.parentFile?.name}/${f.name}" else "$folder/${f.name}") to f
|
||||
}
|
||||
}
|
||||
|
||||
private fun recent(dir: File): List<File> {
|
||||
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
|
||||
return dir.listFiles { f -> f.isFile && f.lastModified() >= since }?.sortedBy { it.name }.orEmpty()
|
||||
|
||||
@@ -4,10 +4,8 @@ import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.CpuAllocationDefaults
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.core.ZramSupport
|
||||
import com.droiddeck.launcher.gpu.Cardboard
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import org.json.JSONObject
|
||||
@@ -51,9 +49,9 @@ object SessionPrefs {
|
||||
)
|
||||
|
||||
fun orientation(context: Context): Int =
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
.takeIf { value -> orientationOptions.any { it.first == value } }
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
|
||||
fun setOrientation(context: Context, orientation: Int) {
|
||||
require(orientationOptions.any { it.first == orientation })
|
||||
@@ -93,13 +91,6 @@ object SessionPrefs {
|
||||
*/
|
||||
fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive"
|
||||
|
||||
fun storesOpenTab(context: Context): String =
|
||||
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
|
||||
|
||||
fun setStoresOpenTab(context: Context, tab: String) {
|
||||
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
|
||||
}
|
||||
|
||||
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
|
||||
|
||||
fun setStoresShowMature(context: Context, show: Boolean) {
|
||||
@@ -137,9 +128,6 @@ object SessionPrefs {
|
||||
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
|
||||
val STORES_VIEWS = listOf("cards", "capsules", "list")
|
||||
|
||||
const val STORES_OPEN_LIBRARY = "library"
|
||||
const val STORES_OPEN_STORE = "store"
|
||||
|
||||
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
|
||||
|
||||
fun setStoresInstallTarget(context: Context, path: String) {
|
||||
@@ -153,6 +141,34 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("storesInstallLanguage", code).apply()
|
||||
}
|
||||
|
||||
const val DEFAULT_STORAGE_ASK = "ask"
|
||||
const val DEFAULT_STORAGE_INTERNAL = "internal"
|
||||
const val DEFAULT_STORAGE_EXTERNAL = "external"
|
||||
private val DEFAULT_STORAGE_VALUES = setOf(DEFAULT_STORAGE_ASK, DEFAULT_STORAGE_INTERNAL, DEFAULT_STORAGE_EXTERNAL)
|
||||
|
||||
fun defaultStorage(context: Context): String =
|
||||
prefs(context).getString("defaultStorage", DEFAULT_STORAGE_ASK)
|
||||
?.takeIf { it in DEFAULT_STORAGE_VALUES } ?: DEFAULT_STORAGE_ASK
|
||||
|
||||
fun setDefaultStorage(context: Context, value: String) {
|
||||
prefs(context).edit().putString("defaultStorage", value.takeIf { it in DEFAULT_STORAGE_VALUES } ?: DEFAULT_STORAGE_ASK).apply()
|
||||
}
|
||||
|
||||
fun defaultStorageFolder(context: Context): String = prefs(context).getString("defaultStorageFolder", "") ?: ""
|
||||
|
||||
fun defaultStorageFolderLabel(context: Context): String = prefs(context).getString("defaultStorageFolderLabel", "") ?: ""
|
||||
|
||||
fun setDefaultStorageFolder(context: Context, path: String, label: String) {
|
||||
prefs(context).edit()
|
||||
.putString("defaultStorageFolder", path)
|
||||
.putString("defaultStorageFolderLabel", label)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clearDefaultStorageFolder(context: Context) {
|
||||
prefs(context).edit().remove("defaultStorageFolder").remove("defaultStorageFolderLabel").apply()
|
||||
}
|
||||
|
||||
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
|
||||
|
||||
fun setGameStoresParallel(context: Context, count: Int) {
|
||||
@@ -264,6 +280,19 @@ object SessionPrefs {
|
||||
const val GPU_DRIVERS_MANUAL = "manual"
|
||||
const val GPU_DRIVERS_MIX = "mix"
|
||||
|
||||
const val TP_UBWC_AUTO = "auto"
|
||||
const val TP_UBWC_ON = "on"
|
||||
const val TP_UBWC_OFF = "off"
|
||||
|
||||
fun tpUbwcMode(context: Context): String =
|
||||
prefs(context).getString("tpUbwcMode", TP_UBWC_AUTO)
|
||||
?.takeIf { it in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF) } ?: TP_UBWC_AUTO
|
||||
|
||||
fun setTpUbwcMode(context: Context, mode: String) {
|
||||
require(mode in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF))
|
||||
prefs(context).edit().putString("tpUbwcMode", mode).commit()
|
||||
}
|
||||
|
||||
fun gpuDriverMode(context: Context): String =
|
||||
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
|
||||
|
||||
@@ -305,6 +334,34 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("returnAfterGame", on).apply()
|
||||
}
|
||||
|
||||
private fun shaderPrecacheApplied(context: Context) =
|
||||
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.bl-shader-precache-applied")
|
||||
|
||||
/**
|
||||
* Shader pre-caching as the launcher last set it, until a session reports it written into the
|
||||
* client's config.vdf; after that the client's own toggle is the user's again.
|
||||
*/
|
||||
fun pendingShaderPrecache(context: Context): Boolean? {
|
||||
val prefs = prefs(context)
|
||||
if (!prefs.contains("shaderPrecache")) return null
|
||||
val on = prefs.getBoolean("shaderPrecache", true)
|
||||
val applied = shaderPrecacheApplied(context)
|
||||
val written = runCatching { applied.readText().trim() }.getOrNull() ?: return on
|
||||
applied.delete()
|
||||
if (written != if (on) "1" else "0") return on
|
||||
prefs.edit().remove("shaderPrecache").apply()
|
||||
return null
|
||||
}
|
||||
|
||||
fun setShaderPrecache(context: Context, on: Boolean) {
|
||||
shaderPrecacheApplied(context).delete()
|
||||
prefs(context).edit().putBoolean("shaderPrecache", on).apply()
|
||||
}
|
||||
|
||||
fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context)
|
||||
?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache(
|
||||
java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf").readText()) }.getOrDefault(true)
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
fun setStretch16x9(context: Context, on: Boolean) {
|
||||
@@ -342,30 +399,19 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
|
||||
}
|
||||
|
||||
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
|
||||
|
||||
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
|
||||
val saved = prefs(context)
|
||||
return CoreAllocation(
|
||||
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
|
||||
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
|
||||
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
|
||||
fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false)
|
||||
|
||||
fun setClientCpusOverride(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
|
||||
}
|
||||
|
||||
fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
|
||||
fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "").orEmpty()
|
||||
|
||||
fun setClientCpus(context: Context, list: String) {
|
||||
prefs(context).edit().putString("clientCpus", list).apply()
|
||||
}
|
||||
|
||||
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
|
||||
fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "").orEmpty()
|
||||
|
||||
fun setGameCpus(context: Context, list: String) {
|
||||
prefs(context).edit().putString("gameCpus", list).apply()
|
||||
@@ -377,7 +423,7 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("noXalia", on).apply()
|
||||
}
|
||||
|
||||
fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false)
|
||||
fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", true)
|
||||
|
||||
fun setFastSync(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("fastSync", on).apply()
|
||||
@@ -412,6 +458,17 @@ object SessionPrefs {
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun applyNtsyncDefault(context: Context) {
|
||||
val prefs = prefs(context)
|
||||
if (prefs.getBoolean("ntsyncDefaultV1", false)) return
|
||||
prefs.edit()
|
||||
.putBoolean("fastSync", true)
|
||||
.putBoolean("fsyncFirst", false)
|
||||
.putBoolean("syncFallback", true)
|
||||
.putBoolean("ntsyncDefaultV1", true)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true)
|
||||
|
||||
fun setSyncFallback(context: Context, on: Boolean) {
|
||||
@@ -424,6 +481,24 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
|
||||
}
|
||||
|
||||
fun ramCapMb(context: Context): Int = prefs(context).getInt("ramCapMb", 0).coerceAtLeast(0)
|
||||
|
||||
fun setRamCapMb(context: Context, mb: Int) {
|
||||
prefs(context).edit().putInt("ramCapMb", mb.coerceAtLeast(0)).apply()
|
||||
}
|
||||
|
||||
fun zramClient(context: Context): Boolean = prefs(context).getBoolean("zramClient", true)
|
||||
|
||||
fun setZramClient(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("zramClient", on).apply()
|
||||
}
|
||||
|
||||
fun vramCapMb(context: Context): Int = prefs(context).getInt("vramCapMb", 0).coerceAtLeast(0)
|
||||
|
||||
fun setVramCapMb(context: Context, mb: Int) {
|
||||
prefs(context).edit().putInt("vramCapMb", mb.coerceAtLeast(0)).apply()
|
||||
}
|
||||
|
||||
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
|
||||
|
||||
fun setGpuClockPin(context: Context, on: Boolean) {
|
||||
@@ -445,6 +520,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("prootFastPath", on).apply()
|
||||
}
|
||||
|
||||
/** gamescope draws the cursor itself. Off by default: on a phone that plane costs frames. */
|
||||
fun cursorComposite(context: Context): Boolean = prefs(context).getBoolean("cursorComposite", false)
|
||||
|
||||
fun setCursorComposite(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("cursorComposite", on).apply()
|
||||
}
|
||||
|
||||
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
|
||||
|
||||
@JvmStatic
|
||||
@@ -518,6 +600,32 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("logs", on).apply()
|
||||
}
|
||||
|
||||
fun battleNetNoSteamSet(context: Context): Boolean = prefs(context).getBoolean("battleNetNoSteamSet", false)
|
||||
fun setBattleNetNoSteamSet(context: Context) { prefs(context).edit().putBoolean("battleNetNoSteamSet", true).apply() }
|
||||
|
||||
fun clearCachedApps(context: Context): Boolean = prefs(context).getBoolean("clearCachedApps", false)
|
||||
fun setClearCachedApps(context: Context, on: Boolean) { prefs(context).edit().putBoolean("clearCachedApps", on).apply() }
|
||||
|
||||
fun backgroundAppLimit(context: Context): Int =
|
||||
prefs(context).getInt("backgroundAppLimit", com.droiddeck.launcher.core.BackgroundAppLimit.OFF)
|
||||
.takeIf { it in com.droiddeck.launcher.core.BackgroundAppLimit.CHOICES } ?: com.droiddeck.launcher.core.BackgroundAppLimit.OFF
|
||||
fun setBackgroundAppLimit(context: Context, limit: Int) {
|
||||
require(limit in com.droiddeck.launcher.core.BackgroundAppLimit.CHOICES)
|
||||
prefs(context).edit().putInt("backgroundAppLimit", limit).apply()
|
||||
}
|
||||
|
||||
const val MEMORY_HUD_OFF = "off"
|
||||
const val MEMORY_HUD_SIMPLE = "simple"
|
||||
const val MEMORY_HUD_ADVANCED = "advanced"
|
||||
|
||||
fun memoryHud(context: Context): String =
|
||||
prefs(context).getString("memoryHud", MEMORY_HUD_OFF)
|
||||
?.takeIf { it == MEMORY_HUD_SIMPLE || it == MEMORY_HUD_ADVANCED } ?: MEMORY_HUD_OFF
|
||||
fun setMemoryHud(context: Context, value: String) {
|
||||
require(value == MEMORY_HUD_OFF || value == MEMORY_HUD_SIMPLE || value == MEMORY_HUD_ADVANCED)
|
||||
prefs(context).edit().putString("memoryHud", value).apply()
|
||||
}
|
||||
|
||||
fun storageDiagnosticsEnabled(context: Context): Boolean =
|
||||
prefs(context).getBoolean("storageDiagnostics", false)
|
||||
|
||||
@@ -765,6 +873,14 @@ object SessionPrefs {
|
||||
it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY)
|
||||
}
|
||||
|
||||
/** How fast the Inner Gamer theme cycles its colours, 1 to 10. */
|
||||
fun rgbSpeed(context: Context): Int =
|
||||
prefs(context).getInt("innerGamerSpeed", com.droiddeck.launcher.ui.Themes.DEFAULT_RGB_SPEED).coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)
|
||||
|
||||
fun setRgbSpeed(context: Context, speed: Int) {
|
||||
prefs(context).edit().putInt("innerGamerSpeed", speed.coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)).apply()
|
||||
}
|
||||
|
||||
fun setGradientColors(context: Context, primary: Int, secondary: Int) {
|
||||
prefs(context).edit().putInt("gradientPrimary", primary).putInt("gradientSecondary", secondary).apply()
|
||||
}
|
||||
@@ -787,7 +903,7 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
|
||||
}
|
||||
|
||||
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
|
||||
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0).coerceAtLeast(0)
|
||||
|
||||
fun setFpsLimit(context: Context, mode: String, fps: Int) {
|
||||
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
|
||||
@@ -798,6 +914,22 @@ object SessionPrefs {
|
||||
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
|
||||
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
|
||||
|
||||
/**
|
||||
* Performance mode: without a frame limit, buffers go back to the game as soon as they are replaced
|
||||
* instead of once per refresh, and gamescope's WSI layer and winex11 reuse their per-present window checks.
|
||||
*/
|
||||
fun performanceMode(context: Context, mode: String): Boolean = prefs(context).getBoolean("performanceMode.$mode", false)
|
||||
|
||||
fun setPerformanceMode(context: Context, mode: String, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("performanceMode.$mode", on).apply()
|
||||
}
|
||||
|
||||
/** Compositor frame limit that releases buffers to the game unpaced. */
|
||||
const val FPS_UNPACED = -1
|
||||
|
||||
/** The limit handed to the compositor: Performance mode without a limit is unpaced, a chosen limit still paces. */
|
||||
fun compositorFpsLimit(limit: Int, performanceMode: Boolean): Int = if (performanceMode && limit <= 0) FPS_UNPACED else limit
|
||||
|
||||
val upscalerChoices = listOf(
|
||||
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
|
||||
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
|
||||
@@ -1051,15 +1183,6 @@ object SessionPrefs {
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun zramCompression(context: Context): Boolean {
|
||||
val prefs = prefs(context)
|
||||
return if (prefs.contains("zramCompression")) prefs.getBoolean("zramCompression", false) else ZramSupport.recommended(context)
|
||||
}
|
||||
|
||||
fun setZramCompression(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("zramCompression", on).apply()
|
||||
}
|
||||
|
||||
|
||||
fun gameStorage(context: Context): String = prefs(context).getString("gameStorage", "") ?: ""
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ class SessionService : Service() {
|
||||
private val steamDownloadMonitor = SteamDownloadMonitor()
|
||||
private var downloadMonitorTask: Runnable? = null
|
||||
private var downloadActive = false
|
||||
private var clientInstallActive = false
|
||||
@Volatile private var steamLibraryMounts = emptyMap<String, File>()
|
||||
private var suspendDownloadNow = false
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
@@ -98,6 +99,8 @@ class SessionService : Service() {
|
||||
private var suspendOperationPending = false
|
||||
private var pipTask = false
|
||||
private var suspendAttemptFailed = false
|
||||
private var freezePending = false
|
||||
private var freezeCancelled = false
|
||||
private var pendingSteamGameId: String? = null
|
||||
private var pendingSteamGameGeneration = -1
|
||||
// Bumped to cancel a running game-exit watch (a new launch, or the session ending).
|
||||
@@ -275,6 +278,16 @@ class SessionService : Service() {
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_MIC_CHANGED -> {
|
||||
if (SessionState.running && SessionState.micAvailable && !SessionState.suspended) {
|
||||
val enabled = intent.getBooleanExtra("enabled", false)
|
||||
val relay = components.firstNotNullOfOrNull { it as? DirectAudioRelayComponent }
|
||||
if (relay?.setMicEnabled(enabled) != true) {
|
||||
android.widget.Toast.makeText(this, R.string.drawer_mic_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SUSPEND_POLICY_CHANGED -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
@@ -319,18 +332,27 @@ class SessionService : Service() {
|
||||
suspendDownloadNow = false
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
clientInstallActive = false
|
||||
steamSleepToken = null
|
||||
completeNativeSleep()
|
||||
suspendOperationPending = false
|
||||
freezePending = false
|
||||
suspendAttemptFailed = false
|
||||
suspendController = null
|
||||
SessionState.micAvailable = false
|
||||
SessionState.micEnabled = false
|
||||
SessionState.suspended = false
|
||||
SessionState.resumeRequested = false
|
||||
SessionState.resuming = false
|
||||
SessionState.resumeFailed = false
|
||||
SessionState.pipActive = false
|
||||
pipTask = false
|
||||
SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM)
|
||||
SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
SessionState.gameId = intent?.getStringExtra(EXTRA_GAME_ID)
|
||||
SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI)
|
||||
SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL)
|
||||
com.droiddeck.launcher.stores.battlenet.BattleNetClient.clearRequests(LinuxRuntime.sessionRoot(this))
|
||||
synchronized(stopLock) {
|
||||
SessionState.steamClientArch = null
|
||||
SessionState.running = true
|
||||
@@ -345,6 +367,9 @@ class SessionService : Service() {
|
||||
SessionEvents.transition(SessionPhase.STARTING_GUEST, "service.started", mapOf("mode" to SessionState.mode))
|
||||
com.droiddeck.launcher.wayland.CompositorHost.newSession()
|
||||
val gen = ++sessionGen
|
||||
SessionPrefs.backgroundAppLimit(this).takeIf { it != com.droiddeck.launcher.core.BackgroundAppLimit.OFF }
|
||||
?.let { com.droiddeck.launcher.core.BackgroundAppLimit.applyAsync(this, it) }
|
||||
if (SessionPrefs.clearCachedApps(this)) clearCachedApps()
|
||||
cancelQueuedSteamGame(clearMailbox = false)
|
||||
clearSteamGameMailbox()
|
||||
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl
|
||||
@@ -352,6 +377,7 @@ class SessionService : Service() {
|
||||
acquireLocks()
|
||||
Thread({
|
||||
OrphanReaper.reap("session starting")
|
||||
ProtonExtras.awaitReplacement(120_000)
|
||||
runSession(gen)
|
||||
}, "session-start").start()
|
||||
return START_NOT_STICKY
|
||||
@@ -391,7 +417,7 @@ class SessionService : Service() {
|
||||
|
||||
private fun runSession(gen: Int) {
|
||||
SessionTerminal.clear()
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)
|
||||
try {
|
||||
LinuxRuntime.writeAccounts(this)
|
||||
} catch (e: Exception) {
|
||||
@@ -406,6 +432,9 @@ class SessionService : Service() {
|
||||
killStragglers()
|
||||
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
|
||||
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
|
||||
val agentEnvironment = runCatching { com.droiddeck.launcher.agent.AgentEnv.beginSession(this) }
|
||||
.onFailure { Log.w(TAG, "agent environment skipped", it) }
|
||||
.getOrDefault(emptyList())
|
||||
SessionFiles.stage(this, root, startsSession = true)
|
||||
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
|
||||
SessionArtifacts.awaitCollections()
|
||||
@@ -429,7 +458,7 @@ class SessionService : Service() {
|
||||
.onSuccess { if (it != null) Log.i(TAG, it) }
|
||||
.onFailure { Log.w(TAG, "forced components", it) }
|
||||
}
|
||||
addClientEnvironment(guest, steamHere)
|
||||
addClientEnvironment(guest, steamHere, agentEnvironment)
|
||||
if (steamHere) guest.add("BL_EPIC_APP_ID=" + com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)
|
||||
val fastPathAt = guest.size
|
||||
|
||||
@@ -450,6 +479,16 @@ class SessionService : Service() {
|
||||
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
|
||||
}
|
||||
guest.add("BL_FPS=" + SessionState.fpsLimit)
|
||||
// gamescope's WSI layer and winex11 reuse their per-present window checks for this long; 0 checks on every present.
|
||||
val windowCheckMs = if (SessionState.performanceMode) 100 else 0
|
||||
guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=$windowCheckMs")
|
||||
guest.add("WINE_OFFSCREEN_CHECK_MS=$windowCheckMs")
|
||||
if (SessionPrefs.compositorFpsLimit(SessionState.fpsLimit, SessionState.performanceMode) == SessionPrefs.FPS_UNPACED) {
|
||||
// gamescope turns buffers around slower than an unpaced game draws; more images keep it drawing.
|
||||
guest.add("GAMESCOPE_WSI_MIN_IMAGE_COUNT=8")
|
||||
guest.add("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1")
|
||||
}
|
||||
Log.i(TAG, "performance mode " + (if (SessionState.performanceMode) "on" else "off") + ", frame limit " + SessionState.fpsLimit)
|
||||
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
|
||||
guest.add("BL_LOG=" + sessionLog.path)
|
||||
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
|
||||
@@ -539,6 +578,9 @@ class SessionService : Service() {
|
||||
Log.i(TAG, "proot: seccomp acceleration off by request")
|
||||
}
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> hostEnv[k] = v } }
|
||||
for (key in listOf("PROOT_PROFILE", "PROOT_PROFILE_SLOW_US")) {
|
||||
guest.lastOrNull { it.startsWith("$key=") }?.substringAfter('=')?.let { hostEnv[key] = it }
|
||||
}
|
||||
val prootLibs = LinuxRuntime.prootLibraryPath(this)
|
||||
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
|
||||
|
||||
@@ -581,7 +623,7 @@ class SessionService : Service() {
|
||||
val program = programStatus(sessionRoot)
|
||||
Log.i(TAG, "session ended: $status" + if (program != null) " (program $program)" else "")
|
||||
stopSession(program ?: status ?: -1)
|
||||
}, null)
|
||||
}, null, sessionLog.takeIf { "PROOT_PROFILE=1" in hostEnv.asArray() })
|
||||
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
|
||||
if (gen != sessionGen || !SessionState.running) {
|
||||
Log.i(TAG, "session stopped while its guest was starting; taking it down")
|
||||
@@ -612,13 +654,30 @@ class SessionService : Service() {
|
||||
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
|
||||
suspendAudio = {
|
||||
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
|
||||
if (!pulse.setMicSuspended(true)) Log.w(TAG, "could not suspend microphone source")
|
||||
val relay = components.firstNotNullOfOrNull { it as? DirectAudioRelayComponent }
|
||||
if (relay != null && !relay.awaitMicReleased(MIC_RELEASE_TIMEOUT_MS)) {
|
||||
Log.w(TAG, "microphone still open after ${MIC_RELEASE_TIMEOUT_MS} ms; suspending anyway")
|
||||
}
|
||||
},
|
||||
resumeAudio = {
|
||||
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
|
||||
if (!pulse.setMicSuspended(false)) Log.w(TAG, "could not resume microphone source")
|
||||
},
|
||||
zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"),
|
||||
pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis ->
|
||||
Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " +
|
||||
"resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB")
|
||||
mainHandler.post {
|
||||
SessionEvents.record("session.paged_out", mapOf(
|
||||
"processes" to processes, "unacknowledged" to unacknowledged, "residentMb" to residentKb / 1024,
|
||||
"swapMb" to swapKb / 1024, "swapBeforeMb" to swapBeforeKb / 1024, "ms" to millis,
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
updateDownloadMonitoring()
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,10 +692,11 @@ class SessionService : Service() {
|
||||
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
|
||||
NetworkReport.write(this, File(sessionDir, "network.txt"))
|
||||
SessionLogCapture.start(File(sessionDir, "app.log"))
|
||||
MemoryLog.start()
|
||||
return sessionDir
|
||||
}
|
||||
|
||||
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
|
||||
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean, agentEnvironment: List<String>) {
|
||||
guest.add("/usr/bin/env")
|
||||
guest.add("-i")
|
||||
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
|
||||
@@ -650,6 +710,7 @@ class SessionService : Service() {
|
||||
com.droiddeck.launcher.core.AppLanguage.system(this).country,
|
||||
))
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
if (SessionState.mode == MODE_STEAM) SessionPrefs.pendingShaderPrecache(this)?.let { guest.add("BL_SHADER_PRECACHE=" + if (it) "1" else "0") }
|
||||
guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR)
|
||||
guest.add("XDG_SESSION_TYPE=wayland")
|
||||
guest.add("WAYLAND_DISPLAY=wayland-0")
|
||||
@@ -672,7 +733,6 @@ class SessionService : Service() {
|
||||
val tuOverride = tuDebugOverride()
|
||||
val tuDriver = sysmemDriver(linuxDriverId)
|
||||
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
|
||||
if (SessionPrefs.zinkLazy(this)) {
|
||||
guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
@@ -685,7 +745,10 @@ class SessionService : Service() {
|
||||
SessionState.steamClientArch = if (steamHere) SessionPrefs.steamClientArch(this) else null
|
||||
SessionState.steamClientArch?.let { arch ->
|
||||
guest.add("BL_STEAM_CLIENT_ARCH=$arch")
|
||||
SessionPaths.current()?.let { SessionArtifacts.recordClient(it, arch) }
|
||||
SessionPaths.current()?.let {
|
||||
SessionArtifacts.recordClient(it, arch)
|
||||
SessionArtifacts.markSteamLogs(it, File(SessionPrefs.steamRoot(this, arch), "logs"))
|
||||
}
|
||||
if (arch == "x86_64") FexCatalog.cached(this)?.let { guest.addAll(FexCatalog.environment(it)) }
|
||||
}
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
@@ -696,12 +759,18 @@ class SessionService : Service() {
|
||||
guest.add("BL_SYNC_FALLBACK=" + (if (SessionPrefs.syncFallback(this)) "1" else "0"))
|
||||
guest.add("BL_FSYNC_FIRST=" + (if (SessionPrefs.fsyncFirst(this)) "1" else "0"))
|
||||
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
|
||||
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
|
||||
if (SessionPrefs.cursorComposite(this)) guest.add("BL_CURSOR_COMPOSITE=1")
|
||||
if (com.droiddeck.launcher.core.ZramSupport.supported()) {
|
||||
guest.add("BL_ZRAM=1")
|
||||
if (steamHere) SessionPrefs.ramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_RAM_CAP_MB=$it") }
|
||||
if (!SessionPrefs.zramClient(this)) guest.add("BL_ZRAM_CLIENT=0")
|
||||
}
|
||||
SessionPrefs.vramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_VRAM_MB=$it") }
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
|
||||
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
|
||||
val extra = extraEnv() + agentEnvironment
|
||||
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
|
||||
extra.forEach { guest.add(it) }
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
|
||||
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
|
||||
if (clientPinned) {
|
||||
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
|
||||
@@ -711,7 +780,7 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun noSteamGame(): Boolean =
|
||||
SessionState.mode == MODE_RUN && SessionState.program in setOf(NoSteamLaunch.PROTON_RUN, NoSteamLaunch.EPIC)
|
||||
SessionState.mode == MODE_RUN && SessionState.program in setOf(NoSteamLaunch.PROTON_RUN, NoSteamLaunch.EPIC, NoSteamLaunch.BATTLENET)
|
||||
|
||||
private fun startAudio(guest: MutableList<String>, sessionDir: File): PulseAudioComponent {
|
||||
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || noSteamGame()) &&
|
||||
@@ -732,6 +801,15 @@ class SessionService : Service() {
|
||||
components.add(pulse)
|
||||
if (wantsDirectAudio || wantsMic) {
|
||||
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
|
||||
val gen = sessionGen
|
||||
relay.onMicEnabled = java.util.function.Consumer { enabled ->
|
||||
mainHandler.post {
|
||||
if (gen == sessionGen && SessionState.running && wantsMic) {
|
||||
SessionState.micAvailable = true
|
||||
SessionState.micEnabled = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
relay.setLogFile(audioLog)
|
||||
relay.attach(this)
|
||||
components.add(relay)
|
||||
@@ -1002,6 +1080,7 @@ class SessionService : Service() {
|
||||
if (!stillSameProcess()) return
|
||||
val tree = descendants(prootPid)
|
||||
if (!stillSameProcess()) return
|
||||
saveWineRegistries(tree)
|
||||
android.os.Process.sendSignal(prootPid, 15)
|
||||
if (!waitForExit(prootPid, GRACE_MS)) {
|
||||
if (!stillSameProcess()) return
|
||||
@@ -1018,6 +1097,31 @@ class SessionService : Service() {
|
||||
if (killed > 0) Log.i(TAG, "swept $killed process(es) proot left behind")
|
||||
}
|
||||
|
||||
private fun clearCachedApps() {
|
||||
val app = applicationContext
|
||||
Thread({
|
||||
val before = MemoryReadout.availableMb()
|
||||
val result = runCatching { com.droiddeck.launcher.core.WirelessAdbFix.clearCachedApps(app) }
|
||||
val after = MemoryReadout.availableMb()
|
||||
result.onSuccess { Log.i(TAG, "cached apps closed: available memory $before -> $after MB") }
|
||||
.onFailure { Log.w(TAG, "cached apps not closed: ${it.message}") }
|
||||
}, "clear-cached-apps").start()
|
||||
}
|
||||
|
||||
private fun saveWineRegistries(tree: List<Pair<Int, Long>>) {
|
||||
val servers = tree.filter { (pid, started) ->
|
||||
readStat(pid)?.second == started && WineProcesses.isWineserver(
|
||||
runCatching { File("/proc/$pid/cmdline").readBytes() }.getOrNull(),
|
||||
runCatching { File("/proc/$pid/comm").readText() }.getOrNull(),
|
||||
)
|
||||
}
|
||||
if (servers.isEmpty()) return
|
||||
servers.forEach { (pid, _) -> android.os.Process.sendSignal(pid, 15) }
|
||||
val deadline = System.currentTimeMillis() + WINE_SAVE_MS
|
||||
val saved = servers.count { (pid, _) -> waitForExit(pid, (deadline - System.currentTimeMillis()).coerceAtLeast(0)) }
|
||||
Log.i(TAG, "wineserver registry save: $saved of ${servers.size} exited after SIGTERM")
|
||||
}
|
||||
|
||||
private fun descendants(root: Int): List<Pair<Int, Long>> {
|
||||
val started = HashMap<Int, Long>()
|
||||
val children = HashMap<Int, MutableList<Int>>()
|
||||
@@ -1191,6 +1295,7 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun resumeSession(panelVisible: Boolean = true) {
|
||||
if (SessionState.suspended) SessionState.resumeRequested = true
|
||||
if (panelVisible) activityVisible = true
|
||||
screenOn = readScreenOn()
|
||||
manualPauseRequested = false
|
||||
@@ -1296,6 +1401,11 @@ class SessionService : Service() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (path == com.droiddeck.launcher.runtime.BattleNet.FEX_REQUEST) {
|
||||
File(dir, path).delete()
|
||||
com.droiddeck.launcher.runtime.BattleNet.answerFexRequest(this@SessionService, dir)
|
||||
return
|
||||
}
|
||||
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
|
||||
val request = File(dir, path)
|
||||
val text = runCatching { request.readText() }.getOrNull() ?: return
|
||||
@@ -1388,10 +1498,12 @@ class SessionService : Service() {
|
||||
private fun updateSuspendPolicy() {
|
||||
if (!SessionState.running) return
|
||||
val hidden = !hasVisibleSurface
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
val downloadsBlockingSuspend = downloadsKeepAwake() && hidden && !suspendDownloadNow
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
|
||||
manualPauseRequested = true
|
||||
} else if (!hidden && !SessionState.suspended) {
|
||||
// Coming back before the freeze finished keeps the game running instead of pausing it a moment later.
|
||||
manualPauseRequested = false
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
@@ -1413,6 +1525,11 @@ class SessionService : Service() {
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
|
||||
else -> false
|
||||
}
|
||||
if (freezePending && !shouldSuspend && !freezeCancelled) {
|
||||
freezeCancelled = true
|
||||
controller.cancelFreeze()
|
||||
}
|
||||
if (shouldSuspend && SessionState.suspended && !suspendOperationPending) SessionState.resumeRequested = false
|
||||
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
|
||||
val gen = sessionGen
|
||||
suspendOperationPending = true
|
||||
@@ -1423,36 +1540,54 @@ class SessionService : Service() {
|
||||
"nativeSleep" to (nativeSleepToken != null),
|
||||
))
|
||||
if (shouldSuspend) {
|
||||
controller.freeze { success ->
|
||||
freezePending = true
|
||||
freezeCancelled = false
|
||||
val freezeSteamToken = steamSleepToken
|
||||
val freezeNativeToken = nativeSleepToken
|
||||
controller.freeze(
|
||||
pageOut = steamSleepToken != null || nativeSleepToken != null || !activityVisible || !screenOn,
|
||||
) { success ->
|
||||
mainHandler.post {
|
||||
if (gen != sessionGen || !SessionState.running) return@post
|
||||
suspendOperationPending = false
|
||||
freezePending = false
|
||||
if (success) {
|
||||
SessionState.suspended = true
|
||||
SessionState.resumeFailed = false
|
||||
SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended")
|
||||
releaseLocks()
|
||||
refreshNotification()
|
||||
} else {
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = true
|
||||
Log.w(TAG, "could not confirm that the session stopped")
|
||||
if (nativeSleepToken == freezeNativeToken) completeNativeSleep()
|
||||
if (steamSleepToken == freezeSteamToken) completeSteamSleep()
|
||||
if (freezeCancelled) {
|
||||
SessionEvents.record("session.suspend_cancelled")
|
||||
} else {
|
||||
suspendAttemptFailed = true
|
||||
Log.w(TAG, "could not confirm that the session stopped")
|
||||
}
|
||||
}
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
acquireLocks()
|
||||
SessionState.resuming = true
|
||||
controller.resume { success ->
|
||||
mainHandler.post {
|
||||
if (gen != sessionGen || !SessionState.running) return@post
|
||||
suspendOperationPending = false
|
||||
SessionState.resuming = false
|
||||
SessionState.resumeRequested = false
|
||||
if (success) {
|
||||
SessionState.suspended = false
|
||||
SessionState.resumeFailed = false
|
||||
SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed")
|
||||
refreshNotification()
|
||||
} else {
|
||||
suspendAttemptFailed = true
|
||||
SessionState.resumeFailures++
|
||||
SessionState.resumeFailed = true
|
||||
Log.w(TAG, "could not confirm that the session resumed")
|
||||
}
|
||||
updateSuspendPolicy()
|
||||
@@ -1542,6 +1677,9 @@ class SessionService : Service() {
|
||||
return@post
|
||||
}
|
||||
SessionState.suspended = false
|
||||
SessionState.resumeRequested = false
|
||||
SessionState.resuming = false
|
||||
SessionState.resumeFailed = false
|
||||
SessionState.pipActive = false
|
||||
pipTask = false
|
||||
SessionState.guestPid = -1
|
||||
@@ -1551,6 +1689,8 @@ class SessionService : Service() {
|
||||
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
|
||||
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
|
||||
releaseLocks()
|
||||
MemoryLog.stop()
|
||||
com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(applicationContext)
|
||||
if (status == 0) {
|
||||
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
|
||||
} else {
|
||||
@@ -1593,20 +1733,24 @@ class SessionService : Service() {
|
||||
completeNativeSleep()
|
||||
val (ended, endedArch) = synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
SessionState.running = false
|
||||
SessionPaths.take() to SessionState.steamClientArch
|
||||
}
|
||||
SessionState.micAvailable = false
|
||||
SessionState.micEnabled = false
|
||||
xrActivityVisible = false
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
val stoppedSession = SessionState.sessionId
|
||||
val launcherEnded = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || SessionState.program == NoSteamLaunch.EPIC
|
||||
val launcherEnded = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || SessionState.program == NoSteamLaunch.EPIC ||
|
||||
SessionState.program == NoSteamLaunch.BATTLENET
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
GpuClockPin.stop(this)
|
||||
GameThreadHints.stop()
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
suspendOperationPending = false
|
||||
freezePending = false
|
||||
launchWatcher?.stopWatching()
|
||||
launchWatcher = null
|
||||
syncWatcher?.stopWatching()
|
||||
@@ -1661,6 +1805,9 @@ class SessionService : Service() {
|
||||
private fun importLauncherGames() {
|
||||
runCatching { com.droiddeck.launcher.stores.epic.EpicClientLibrary.importClientGames(this) }
|
||||
.onFailure { Log.w(TAG, "client library scan failed: ${it.javaClass.simpleName}") }
|
||||
runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(this) }
|
||||
.onFailure { Log.w(TAG, "Battle.net sign-in check failed: ${it.javaClass.simpleName}") }
|
||||
com.droiddeck.launcher.stores.StoresState.notifyLibraryChanged(this)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
@@ -1773,8 +1920,7 @@ class SessionService : Service() {
|
||||
.setContentText(getString(
|
||||
when {
|
||||
SessionState.suspended -> R.string.session_notification_paused
|
||||
downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
|
||||
downloadsKeepAwake() -> R.string.session_notification_downloads
|
||||
else -> R.string.session_notification
|
||||
}
|
||||
))
|
||||
@@ -1782,8 +1928,7 @@ class SessionService : Service() {
|
||||
.apply {
|
||||
if (SessionState.suspended) {
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
|
||||
} else if (downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
|
||||
} else if (downloadsKeepAwake()) {
|
||||
val suspend = PendingIntent.getService(
|
||||
this@SessionService, 3,
|
||||
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
|
||||
@@ -1803,33 +1948,50 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
private fun downloadsKeepAwake(): Boolean =
|
||||
downloadActive && (steamDownloadsInBackground || clientInstallActive) &&
|
||||
SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
|
||||
private fun downloadWatchWanted(): Boolean =
|
||||
SessionState.running && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER &&
|
||||
(steamDownloadsInBackground || !SessionState.firstFrameSeen)
|
||||
|
||||
private fun updateDownloadMonitoring() {
|
||||
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
|
||||
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
if (!enabled) {
|
||||
if (!downloadWatchWanted()) {
|
||||
val wasActive = downloadActive
|
||||
downloadMonitorTask?.let(mainHandler::removeCallbacks)
|
||||
downloadMonitorTask = null
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
if (clientInstallActive) SessionEvents.record("steam.client_download", mapOf("active" to false))
|
||||
clientInstallActive = false
|
||||
if (wasActive) refreshNotification()
|
||||
return
|
||||
}
|
||||
if (downloadMonitorTask != null) return
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (downloadMonitorTask !== this || !SessionState.running ||
|
||||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
|
||||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
|
||||
if (downloadMonitorTask !== this) return
|
||||
if (!downloadWatchWanted()) {
|
||||
downloadMonitorTask = null
|
||||
updateDownloadMonitoring()
|
||||
updateSuspendPolicy()
|
||||
return
|
||||
}
|
||||
val root = LinuxRuntime.rootDir(this@SessionService)
|
||||
val wasActive = downloadActive
|
||||
downloadActive = steamDownloadMonitor.poll(
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
val wasInstalling = clientInstallActive
|
||||
clientInstallActive = !SessionState.firstFrameSeen &&
|
||||
(clientInstallActive || SteamDownloadMonitor.clientInstallPending(root))
|
||||
downloadActive = clientInstallActive || steamDownloadsInBackground && steamDownloadMonitor.poll(
|
||||
root,
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
client = SessionState.steamClientArch,
|
||||
mounts = steamLibraryMounts,
|
||||
)
|
||||
if (wasInstalling != clientInstallActive) {
|
||||
SessionEvents.record("steam.client_download", mapOf("active" to clientInstallActive))
|
||||
}
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
|
||||
@@ -1855,6 +2017,7 @@ class SessionService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
private const val TRACER_NICE = -6
|
||||
private const val MIC_RELEASE_TIMEOUT_MS = 5_000L
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
|
||||
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
|
||||
@@ -1873,6 +2036,7 @@ class SessionService : Service() {
|
||||
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
|
||||
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
|
||||
const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START"
|
||||
private const val ACTION_MIC_CHANGED = "com.droiddeck.launcher.MIC_CHANGED"
|
||||
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
|
||||
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
|
||||
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
|
||||
@@ -1891,6 +2055,7 @@ class SessionService : Service() {
|
||||
private val STRAGGLERS = listOf("droiddeck-session", "gamescope", "Xwayland", "steamrtarm64",
|
||||
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
|
||||
private const val GRACE_MS = 1200L
|
||||
private const val WINE_SAVE_MS = 3000L
|
||||
private const val STEAM_PICKUP_MS = 1500L
|
||||
private const val STEAM_EXIT_MS = 10_000L
|
||||
private const val GAME_LAUNCH_RETRY_MS = 250L
|
||||
@@ -1922,6 +2087,7 @@ class SessionService : Service() {
|
||||
fun start(
|
||||
context: Context, mode: String = MODE_STEAM, program: String? = null,
|
||||
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
|
||||
gameId: String? = null,
|
||||
displayId: Int = Display.DEFAULT_DISPLAY,
|
||||
) {
|
||||
SessionState.activityDisplayId = displayId
|
||||
@@ -1932,6 +2098,7 @@ class SessionService : Service() {
|
||||
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
|
||||
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
|
||||
if (steamUrl != null) intent.putExtra(EXTRA_STEAM_URL, steamUrl)
|
||||
if (gameId != null) intent.putExtra(EXTRA_GAME_ID, gameId)
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent)
|
||||
else context.startService(intent)
|
||||
}
|
||||
@@ -1986,6 +2153,7 @@ class SessionService : Service() {
|
||||
|
||||
fun resume(context: Context) {
|
||||
if (!SessionState.running) return
|
||||
if (SessionState.suspended) SessionState.resumeRequested = true
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
|
||||
}
|
||||
|
||||
@@ -2002,6 +2170,11 @@ class SessionService : Service() {
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun setMicEnabled(context: Context, enabled: Boolean) {
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_MIC_CHANGED)
|
||||
.putExtra("enabled", enabled))
|
||||
}
|
||||
|
||||
fun suspendPolicyChanged(context: Context) {
|
||||
if (!SessionState.running) return
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SUSPEND_POLICY_CHANGED))
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
@@ -45,13 +46,21 @@ object SessionState {
|
||||
var activityDisplayId = 0
|
||||
@Volatile
|
||||
var runtimeDriver: String? = null
|
||||
var micAvailable by mutableStateOf(false)
|
||||
var micEnabled by mutableStateOf(false)
|
||||
var suspended by mutableStateOf(false)
|
||||
var resumeRequested by mutableStateOf(false)
|
||||
var resuming by mutableStateOf(false)
|
||||
var resumeFailures by mutableIntStateOf(0)
|
||||
var resumeFailed by mutableStateOf(false)
|
||||
@Volatile var pipActive = false
|
||||
@Volatile
|
||||
var program: String? = null
|
||||
@Volatile
|
||||
var programArgs: List<String> = emptyList()
|
||||
@Volatile
|
||||
var gameId: String? = null
|
||||
@Volatile
|
||||
var steamUi: String? = null
|
||||
@Volatile
|
||||
var steamUrl: String? = null
|
||||
@@ -97,6 +106,9 @@ object SessionState {
|
||||
@Volatile
|
||||
var fpsLimit: Int = 0
|
||||
|
||||
@Volatile
|
||||
var performanceMode = false
|
||||
|
||||
@Volatile
|
||||
var firstFrameSeen = false
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ internal class SessionSuspendController(
|
||||
private val sleep: (Long) -> Unit = Thread::sleep,
|
||||
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
|
||||
private val zramControl: File? = null,
|
||||
private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit =
|
||||
{ _, _, _, _, _, _ -> },
|
||||
) {
|
||||
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
|
||||
|
||||
@@ -38,9 +41,11 @@ internal class SessionSuspendController(
|
||||
private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long)
|
||||
|
||||
@Volatile private var closing = false
|
||||
@Volatile private var cancelled = false
|
||||
private var frozen = emptyList<ProcessRecord>()
|
||||
|
||||
fun freeze(completion: (Boolean) -> Unit) {
|
||||
fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) {
|
||||
cancelled = false
|
||||
submit({
|
||||
val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started }
|
||||
var complete = false
|
||||
@@ -59,17 +64,18 @@ internal class SessionSuspendController(
|
||||
records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT)
|
||||
}
|
||||
}
|
||||
if (pageOut) pageOut(records, root.pid, helpers)
|
||||
val deadline = nowMillis() + FREEZE_TIMEOUT_MS
|
||||
var previous: Map<TaskIdentity, Long>? = null
|
||||
for (pass in 0 until FREEZE_MAX_PASSES) {
|
||||
if (closing || nowMillis() >= deadline) break
|
||||
if (closing || cancelled || nowMillis() >= deadline) break
|
||||
try {
|
||||
if (readProcess(root.pid)?.started != root.started) break
|
||||
addSnapshot(records, root.pid, helpers)
|
||||
if (readProcess(root.pid)?.started != root.started) break
|
||||
stop(records.values.toList())
|
||||
sleep(if (pass == 0) 70L else 35L)
|
||||
if (closing || nowMillis() >= deadline) break
|
||||
if (closing || cancelled || nowMillis() >= deadline) break
|
||||
val current = stoppedSnapshot(records.values.toList(), root)
|
||||
if (current != null && current == previous) {
|
||||
complete = true
|
||||
@@ -97,6 +103,11 @@ internal class SessionSuspendController(
|
||||
}, { completion(false) })
|
||||
}
|
||||
|
||||
// A freeze still queued or paging out stops before its next SIGSTOP pass and rolls back.
|
||||
fun cancelFreeze() {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
fun resume(completion: (Boolean) -> Unit) {
|
||||
submit({
|
||||
val records = frozen
|
||||
@@ -233,6 +244,65 @@ internal class SessionSuspendController(
|
||||
return tasks.takeIf { stopped && it.isNotEmpty() }
|
||||
}
|
||||
|
||||
// A stopped process cannot run libblsession's SIGURG handler, so the page-out has to finish
|
||||
// before the stop. Android's low-memory killer otherwise finds the frozen game resident in RAM
|
||||
// with most of zram still free.
|
||||
private fun pageOut(records: MutableMap<Pair<Int, Long>, ProcessRecord>, root: Int, helpers: List<Int>) {
|
||||
val control = zramControl?.takeIf { it.isFile } ?: return
|
||||
try {
|
||||
addSnapshot(records, root, helpers)
|
||||
control.writeText("1")
|
||||
} catch (_: IOException) {
|
||||
return
|
||||
}
|
||||
val targets = records.values.filter { record ->
|
||||
try {
|
||||
File(procRoot, "${record.pid}/maps").useLines { lines -> lines.any { it.endsWith("/libblsession.so") } }
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (targets.isEmpty()) return
|
||||
val acks = targets.associateWith { File(control.parentFile, "${control.name}.ack.${it.pid}") }
|
||||
acks.values.forEach { it.delete() }
|
||||
val start = nowMillis()
|
||||
val before = memoryKb(targets)
|
||||
for (record in targets) send(record, SIGURG)
|
||||
// A program can replace libblsession's SIGURG handler (PyInstaller's bootloader, the Go runtime);
|
||||
// such a process never creates its ack and is not waited for once the start window has passed.
|
||||
val waiting = targets.toMutableList()
|
||||
while (!closing && !cancelled && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) {
|
||||
val started = nowMillis() - start >= PAGE_OUT_START_MS
|
||||
waiting.removeAll { val ack = acks.getValue(it); ack.length() > 0 || (started && !ack.exists()) || isGone(it) }
|
||||
if (waiting.isEmpty()) break
|
||||
sleep(PAGE_OUT_POLL_MS)
|
||||
}
|
||||
val unacknowledged = targets.count { acks.getValue(it).length() == 0L && !isGone(it) }
|
||||
acks.values.forEach { it.delete() }
|
||||
val after = memoryKb(targets)
|
||||
pagedOut(targets.size, unacknowledged, after.first, after.second, before.second, nowMillis() - start)
|
||||
}
|
||||
|
||||
private fun isGone(record: ProcessRecord): Boolean =
|
||||
try { readProcess(record.pid)?.started != record.started } catch (_: Exception) { false }
|
||||
|
||||
private fun memoryKb(records: List<ProcessRecord>): Pair<Long, Long> {
|
||||
var resident = 0L
|
||||
var swap = 0L
|
||||
for (record in records) {
|
||||
try {
|
||||
File(procRoot, "${record.pid}/status").useLines { lines ->
|
||||
for (line in lines) {
|
||||
val kb = line.substringAfter(':').trim().substringBefore(' ').toLongOrNull() ?: continue
|
||||
if (line.startsWith("VmRSS:")) resident += kb else if (line.startsWith("VmSwap:")) swap += kb
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
}
|
||||
return resident to swap
|
||||
}
|
||||
|
||||
// PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
|
||||
// thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
|
||||
// signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
|
||||
@@ -310,6 +380,10 @@ internal class SessionSuspendController(
|
||||
private companion object {
|
||||
const val SIGSTOP = 19
|
||||
const val SIGCONT = 18
|
||||
const val SIGURG = 23
|
||||
const val PAGE_OUT_POLL_MS = 100L
|
||||
const val PAGE_OUT_START_MS = 2_000L
|
||||
const val PAGE_OUT_TIMEOUT_MS = 12_000L
|
||||
const val RESTORE_CHECKS = 80
|
||||
const val FREEZE_TIMEOUT_MS = 8_000L
|
||||
const val FREEZE_MAX_PASSES = 256
|
||||
|
||||
@@ -18,9 +18,9 @@ object SettingsReset {
|
||||
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
|
||||
"stretch16x9", "picturePosition",
|
||||
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
|
||||
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
|
||||
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
|
||||
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
|
||||
@@ -29,7 +29,7 @@ object SettingsReset {
|
||||
)
|
||||
|
||||
internal val SESSION_PREFIXES = listOf(
|
||||
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "suspendPolicy.",
|
||||
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
|
||||
)
|
||||
|
||||
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.io.FileInputStream
|
||||
|
||||
internal class SteamDownloadMonitor {
|
||||
private data class ManifestState(
|
||||
@@ -63,16 +63,17 @@ internal class SteamDownloadMonitor {
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
runCatching {
|
||||
RandomAccessFile(log, "r").use { input ->
|
||||
input.seek(contentLogOffset)
|
||||
FileInputStream(log).use { input ->
|
||||
input.channel.position(contentLogOffset)
|
||||
val reader = input.bufferedReader(Charsets.ISO_8859_1)
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
"Client version:" in line -> {
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
CONTENT_UPDATE.find(line)?.let { match ->
|
||||
" update changed : " in line && CONTENT_UPDATE.find(line)?.let { match ->
|
||||
val appId = match.groupValues[1]
|
||||
val updateType = match.groupValues[2]
|
||||
val state = match.groupValues[3]
|
||||
@@ -85,14 +86,15 @@ internal class SteamDownloadMonitor {
|
||||
}
|
||||
true
|
||||
} == true -> Unit
|
||||
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
|
||||
"Current download rate: " in line &&
|
||||
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
|
||||
lastContentActivityAt = now
|
||||
"Downloading " in line && " chunks for depot " in line ->
|
||||
lastContentActivityAt = now
|
||||
}
|
||||
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
|
||||
}
|
||||
contentLogOffset = input.filePointer
|
||||
contentLogOffset = input.channel.position()
|
||||
}
|
||||
}
|
||||
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
|
||||
@@ -138,13 +140,21 @@ internal class SteamDownloadMonitor {
|
||||
private fun value(text: String, key: String): String? =
|
||||
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
|
||||
|
||||
private companion object {
|
||||
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
|
||||
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
companion object {
|
||||
fun clientInstallPending(runtimeRoot: File): Boolean {
|
||||
val home = File(runtimeRoot, "root")
|
||||
val steam = File(home, ".local/share/Steam")
|
||||
val stamped = File(steam, "package/droiddeck-installed").isFile ||
|
||||
File(steam, "package/bannerlator-installed").isFile
|
||||
return !(stamped && File(steam, "steamrtarm64/steam").isFile) || File(home, ".bl-steam-repair").exists()
|
||||
}
|
||||
|
||||
private const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
private const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
private const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
private val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
private val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
|
||||
private val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
private val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
}
|
||||
}
|
||||
@@ -101,10 +101,28 @@ object WinComponents {
|
||||
fun cached(context: Context): List<Component>? =
|
||||
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
|
||||
|
||||
const val VCREDIST_ARM64 = "vcredist2022_arm64"
|
||||
|
||||
/**
|
||||
* The ARM64 Visual C++ 2015-2022 runtime as registry values only (the version a 14.44.35211 installer writes, in both
|
||||
* registry views). Xbox games' launchers running as ARM64EC check for it; Wine's own CRT does the work. Same approach as
|
||||
* MGamer404's Bannerlator #627. Used until the catalog carries a component of this name.
|
||||
*/
|
||||
internal fun builtInArm64Runtime(): Component {
|
||||
fun value(view: String, name: String, type: String, data: String) = Step("set_register_key", JSONObject()
|
||||
.put("action", "set_register_key").put("key", "HKLM\\Software\\${view}Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64")
|
||||
.put("value", name).put("type", type).put("data", data))
|
||||
val steps = listOf("", "Wow6432Node\\").flatMap { view ->
|
||||
listOf(value(view, "Installed", "REG_DWORD", "1"), value(view, "Major", "REG_DWORD", "14"), value(view, "Minor", "REG_DWORD", "44"),
|
||||
value(view, "Bld", "REG_DWORD", "35211"), value(view, "Rbld", "REG_DWORD", "0"), value(view, "Version", "REG_SZ", "v14.44.35211.00"))
|
||||
}
|
||||
return Component(VCREDIST_ARM64, "Microsoft Visual C++ 2015-2022 ARM64 runtime 14.44.35211 (registry entries only)", "Microsoft", "ready", emptyList(), steps)
|
||||
}
|
||||
|
||||
private fun parse(body: String): List<Component>? {
|
||||
return try {
|
||||
val arr = JSONObject(body).getJSONArray("components")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val listed = (0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name")
|
||||
if (!validId(name)) return@mapNotNull null
|
||||
@@ -117,6 +135,7 @@ object WinComponents {
|
||||
o.optString("snapshot"),
|
||||
)
|
||||
}
|
||||
if (listed.any { it.name == VCREDIST_ARM64 }) listed else listed + builtInArm64Runtime()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "catalog: $e"); null
|
||||
}
|
||||
|
||||
Loaded 100 of 538 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user