diff --git a/.github/workflows/authorship.yml b/.github/workflows/authorship.yml
index 97c6637a8..ebbcae644 100644
--- a/.github/workflows/authorship.yml
+++ b/.github/workflows/authorship.yml
@@ -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:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index aacad45f0..146e7fe3b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -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=""
- {
- 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 "What went wrong (Gradle)
"
- echo
- echo '```'
- awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
- echo '```'
- echo " "
- ;;
- 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
diff --git a/README.md b/README.md
index 97043973e..76a02242b 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,20 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big

+
+
+
+
+
+
+
+
+
+
+
+
+
+

> 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-.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//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-.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//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//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 driver in `Documentation/userspace-api/ntsync.rst` (GPL-2.0). Xbox games load the Windows modules of [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) runtime (LGPL-2.1-or-later), including its `wintypes.dll`, whose `ApiInformation` answer Minecraft for Windows needs to start; how Minecraft installs GameInput under Wine was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
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.
diff --git a/app/build.gradle b/app/build.gradle
index c889af588..6fdfe9c4b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 543994093..a1c126962 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,15 @@
+
+
+
+
+
+
+
+
+
@@ -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 @@
@@ -84,8 +94,8 @@
@@ -97,7 +107,7 @@
@@ -151,6 +161,27 @@
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/xcloud/player.js b/app/src/main/assets/xcloud/player.js
new file mode 100644
index 000000000..3d3762149
--- /dev/null
+++ b/app/src/main/assets/xcloud/player.js
@@ -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,RightShoulder:0,LeftThumb:0,RightThumb:0,LeftThumbXAxis:0,LeftThumbYAxis:0,RightThumbXAxis:0,RightThumbYAxis:0,LeftTrigger:0,RightTrigger:0}}detectActiveGamepad(){if(this._physicalGamepadId<0)for(let e in navigator.getGamepads()){let t=navigator.getGamepads()[e]?.index;if(t===void 0)continue;let n=!1;for(let i in this._player?._channels.control.getGamepadHandlers())if(this._player?._channels.control.getGamepadHandlers()[i]instanceof r&&(this._player?._channels.control.getGamepadHandlers()[i])._physicalGamepadId===navigator.getGamepads()[e]?.index){console.log("[Gamepad] Active gamepad already detected:",t,(this._player?._channels.control.getGamepadHandlers()[i])._physicalGamepadId),n=!0;continue}n===!1&&(this._physicalGamepadId=t,console.log("[Gamepad] Active gamepad detected:",this._physicalGamepadId))}else console.log("[Gamepad] Physical gamepad already detected:",this._physicalGamepadId)}onGamepadConnected(e){if(this._physicalGamepadId<0){for(let t in this._player?._channels.control.getGamepadHandlers())if(this._player?._channels.control.getGamepadHandlers()[t]instanceof r&&(this._player?._channels.control.getGamepadHandlers()[t])._physicalGamepadId===e.gamepad.index)return;this._physicalGamepadId=e.gamepad.index}}onGamepadDisconnected(e){e.gamepad.index===this._physicalGamepadId&&(this._physicalGamepadId=-1)}hasOverrideButtons(){return Object.keys(this._gamepadOverride.buttons).length>0}getGamepadState(){let e=this.getGamepad(this._physicalGamepadId);if((this._physicalGamepadId<0||e===void 0||this._isFocused===!1)&&this.hasOverrideButtons()===!1)return;let t=this.getDefaultFamepadFrame();if(t.GamepadIndex=this._index,e!==void 0){for(let i in this._options.gamepad_mapping)t[i]=e.buttons[this._options.gamepad_mapping[i]]?.value??0;for(let i in this._options.gamepad_axes_mapping)t[i]=this.normaliseAxis(e.axes[this._options.gamepad_axes_mapping[i]]??0)}for(let i in this._gamepadOverride.buttons)t[i]=this._gamepadOverride.buttons[i];let n=[];for(let i in this._gamepadOverride.buttons)this._gamepadOverride.buttons[i]===0&&n.push(i);for(let i of n)delete this._gamepadOverride.buttons[i];return t.View>0&&t.Menu>0&&(t.View=0,t.Menu=0,t.Nexus=1),t}getGamepad(e=-1){let t=navigator.getGamepads()[e];if(t!==null)return t}getPhysicalGamepadId(){return this._physicalGamepadId}handleVibration(e){if(this._physicalGamepadId<0){console.log("[Gamepad] Received a vibration report but no physical gamepad is connected");return}let t=this.getGamepad(this._physicalGamepadId);if(t===void 0){console.log("[Gamepad] Received a vibration report but no physical gamepad is connected");return}if(this._options.enable_vibration===!1)return;let n={startDelay:0,duration:e.durationMs,weakMagnitude:e.rightMotorPercent*(window.__ddRumble??1),strongMagnitude:e.leftMotorPercent*(window.__ddRumble??1),leftTrigger:e.leftTriggerMotorPercent,rightTrigger:e.rightTriggerMotorPercent};if(t.vibrationActuator?.type==="dual-rumble"){let i=e.rightMotorPercent<.6?(.6-e.rightMotorPercent)/2:0,a=(e.leftTriggerMotorPercent+e.rightTriggerMotorPercent)/4,s=Math.min(i,a);if(n.weakMagnitude=Math.min(1,(e.rightMotorPercent+s)*(window.__ddRumble??1)),n.leftTrigger=0,n.rightTrigger=0,t.vibrationActuator?.playEffect("dual-rumble",n),clearInterval(this._rumbleInterval),e.repeat>0){let o=e.repeat;this._rumbleInterval=setInterval(()=>{o<=0&&clearInterval(this._rumbleInterval),t.vibrationActuator!==void 0&&t.vibrationActuator?.playEffect("dual-rumble",n),o--},e.delayMs+e.durationMs)}}else console.log("[Gamepad] Unknown vibration type:",t.vibrationActuator.type)}normaliseAxis(e){return Math.abs(e)0&&(this._reportType|=1,s=s+this._calculateMetadataSize(e),this._metadataFrames=e),t.length>0&&(this._reportType|=2,s=s+this._calculateGamepadSize(t),this._gamepadFrames=t),n.length>0&&(this._reportType|=4,s=s+this._calculatePointerSize(n),this._pointerFrames=n),i.length>0&&(this._reportType|=32,s=s+this._calculateMouseSize(i),this._mouseFrames=i),a.length>0&&(this._reportType|=64,s=s+this._calculateKeyboardSize(a),this._keyboardFrames=a),this._totalSize=s}_calculateMetadataSize(e){return 1+7*4*e.length}_calculateGamepadSize(e){return 1+23*e.length}_calculatePointerSize(e){let t=1;for(let n in e)t=t+1+e[n].events.length*20;return t}_calculateMouseSize(e){return 1+18*e.length}_calculateKeyboardSize(e){return 1+3*e.length}_writeMetadataData(e,t,n){e.setUint8(t,n.length),t++,n.length>=30&&console.warn("metadataQueue is bigger then 30. This might impact reliability!");for(let i=0;i=30&&console.warn("gamepadQueue is bigger then 30. This might impact reliability!");for(let i=0;i0&&(s|=2),a.Menu>0&&(s|=4),a.View>0&&(s|=8),a.A>0&&(s|=16),a.B>0&&(s|=32),a.X>0&&(s|=64),a.Y>0&&(s|=128),a.DPadUp>0&&(s|=256),a.DPadDown>0&&(s|=512),a.DPadLeft>0&&(s|=1024),a.DPadRight>0&&(s|=2048),a.LeftShoulder>0&&(s|=4096),a.RightShoulder>0&&(s|=8192),a.LeftThumb>0&&(s|=16384),a.RightThumb>0&&(s|=32768),e.setUint16(t,s,!0),e.setInt16(t+2,this._normalizeAxisValue(a.LeftThumbXAxis),!0),e.setInt16(t+4,this._normalizeAxisValue(-a.LeftThumbYAxis),!0),e.setInt16(t+6,this._normalizeAxisValue(a.RightThumbXAxis),!0),e.setInt16(t+8,this._normalizeAxisValue(-a.RightThumbYAxis),!0),e.setUint16(t+10,this._normalizeTriggerValue(a.LeftTrigger),!0),e.setUint16(t+12,this._normalizeTriggerValue(a.RightTrigger),!0),e.setUint32(t+14,1,!0),e.setUint32(t+18,1,!1),t+=22}return t}_writePointerData(e,t,n){e.setUint8(t,1),t++,n.length>=2&&console.warn("pointerQueue is bigger then 1. Only one event will be sent.");let i=n[0];if(i!==void 0){e.setUint8(t,i.events.length),t++;let a=this._serverVideoWidth,s=this._serverVideoHeight;for(let o=0;o=30&&console.warn("mouseQueue is bigger then 30. This might impact reliability!");for(let i=0;i=30&&console.warn("keyboardQueue is bigger then 30. This might impact reliability!");for(let i=0;i0&&(n=this._writeMetadataData(t,n,this._metadataFrames)),this._gamepadFrames.length>0&&(n=this._writeGamepadData(t,n,this._gamepadFrames)),this._pointerFrames.length>0&&(n=this._writePointerData(t,n,this._pointerFrames)),this._mouseFrames.length>0&&(n=this._writeMouseData(t,n,this._mouseFrames)),this._keyboardFrames.length>0&&(n=this._writeKeyboardData(t,n,this._keyboardFrames)),this._reportType===8&&(t.setUint8(n,this._maxTouchpoints),n++),n!==n)throw new Error("Packet length mismatch. Something is wrong!");return t}_normalizeTriggerValue(e){return e<=0||Number.isNaN(e)?0:e>=1?65535:e*65535|0}_normalizeAxisValue(e){let i=Math.round(e*32767);return i>32767?32767:i<-32767?-32767:i}};var F=class{_player;_inputSequenceNum=0;_metadataQueue=[];_gamepadQueue=[];_pointerQueue=[];_mouseQueue=[];_keyboardQueue=[];_gamepadHeartbeatTimers=new Map;_lastGamepadState=new Map;_axisChangeThreshold=.08;constructor(e){this._player=e}_hasQueuedData(){return this._metadataQueue.length>0||this._gamepadQueue.length>0||this._pointerQueue.length>0||this._mouseQueue.length>0||this._keyboardQueue.length>0}_upsertGamepadFrame(e){for(let t=0;tthis._axisChangeThreshold||o>.15)return!0}for(let a of i)if(Math.abs(e[a]-t[a])>.01)return!0;return!1}_isHeartbeatDue(e){let t=this._gamepadHeartbeatTimers.get(e)??0;return performance.now()-t>33}queueMetadataFrame(e){this._metadataQueue.push(e),this.checkQueueAndSend()}queueGamepadFrame(e){let t=this._lastGamepadState.get(e.GamepadIndex),n=this._hasSignificantAxisChange(e,t),i=this._isHeartbeatDue(e.GamepadIndex);(n||i)&&(this._upsertGamepadFrame(e),this._lastGamepadState.set(e.GamepadIndex,e),this._gamepadHeartbeatTimers.set(e.GamepadIndex,performance.now()),this.checkQueueAndSend())}queueGamepadFrames(e,t=!1){for(let n of e)this._upsertGamepadFrame(n);this.checkQueueAndSend(t)}queueMouseFrame(e){this._mouseQueue.push(e),this.checkQueueAndSend()}queueKeyboardFrame(e){this._keyboardQueue.push(e),this.checkQueueAndSend()}queuePointerFrame(e){this._pointerQueue.length>0?this._pointerQueue[0].events=this._pointerQueue[0].events.concat(e.events):this._pointerQueue.push(e),this.checkQueueAndSend()}checkQueueAndSend(e=!1){if(e===!0){this._hasQueuedData()&&this.sendQueue();return}if(this._gamepadQueue.length>0){this.sendQueue();return}if(this._mouseQueue.length>0||this._keyboardQueue.length>0||this._pointerQueue.length>0){this.sendQueue();return}this._metadataQueue.length>5&&this.sendQueue()}sendQueue(){let e=new y(this.getSequenceNum());this._pointerQueue.length>0&&e.setStreamConfig({width:this._player._channels.input.getServerVideoWidth(),height:this._player._channels.input.getServerVideoHeight()}),e.setData(this._metadataQueue,this._gamepadQueue,this._pointerQueue,this._mouseQueue,this._keyboardQueue),this._player._channels.input.send(e.toBuffer()),this.clearQueues()}clearQueues(){this._metadataQueue=[],this._gamepadQueue=[],this._pointerQueue=[],this._mouseQueue=[],this._keyboardQueue=[]}getSequenceNum(){return this._inputSequenceNum++,this._inputSequenceNum}};var A=class extends _{_serverVideoWidth=0;_serverVideoHeight=0;_inputQueue=new F(this.getPlayer());getChannelName(){return"input"}getChannelConfig(){return{ordered:!0,protocol:"1.0"}}getServerVideoWidth(){return this._serverVideoWidth}getServerVideoHeight(){return this._serverVideoHeight}start(){let e=new y(0);e.setMetadata(navigator.maxTouchPoints>1?navigator.maxTouchPoints:1),this.send(e.toBuffer()),console.log("[InputChannel] Sent metadata:",e,e.toBuffer()),setTimeout(this.gamepadStateLoop.bind(this),16)}onMessage(e){let t=new DataView(e.data),n=t.getUint8(0);if(console.log("[InputChannel] received:",t),n===128){console.log("Received a vibration report"),t.getUint8(2);let i=t.getUint8(3),a=this.getPlayer()._channels.control.getGamepadHandler(i);if(a!==void 0){let s=t.getUint8(4)/100,o=t.getUint8(5)/100,d=t.getUint8(6)/100,u=t.getUint8(7)/100,h=t.getUint16(8,!0),g=t.getUint16(10,!0),p=t.getUint8(12),c={gamepadIndex:i,leftMotorPercent:s,rightMotorPercent:o,leftTriggerMotorPercent:d,rightTriggerMotorPercent:u,durationMs:h,delayMs:g,repeat:p};a.handleVibration(c)}else console.log("[InputChannel] Received a vibration report but no gamepad handler is available")}else n===16&&(console.log("[InputChannel] Received server video dimensions:",t.getUint32(2,!0),t.getUint32(6,!0)),this._serverVideoHeight=t.getUint32(2,!0),this._serverVideoWidth=t.getUint32(6,!0))}gamepadStateLoop(){let e=this.getPlayer()._channels.control.getGamepadHandlers(),t=[];for(let n in e)if(e[n]instanceof v){let i=e[n].getGamepadState();i!==void 0&&t.push(i)}t.length>0&&this.queueGamepadFrames(t),setTimeout(this.gamepadStateLoop.bind(this),16)}queueMetadataFrame(e){return this._inputQueue.queueMetadataFrame(e)}queueGamepadFrames(e,t=!1){this._inputQueue.queueGamepadFrames(e,t)}queueMouseFrame(e){this._inputQueue.queueMouseFrame(e)}queueKeyboardFrame(e){this._inputQueue.queueKeyboardFrame(e)}queuePointerFrame(e){this._inputQueue.queuePointerFrame(e)}destroy(){}};function Q(){return crypto.randomUUID()}var D=class{_channel;_data;constructor(e,t){this._channel=e,this._data=t,this.handle(this._data)}handle(e){switch(console.log("[MessageHandler] Handling:",e),e.target){case"/streaming/sessionLifetimeManagement/serverInitiatedDisconnect":this.completeTransaction(e.id,""),this._channel.getPlayer().destroy();break;case"/streaming/properties/titleinfo":console.log("Title update:",e);break;case"/streaming/touchcontrols/showlayoutv2":break;case"/streaming/systemUi/messages/ShowMessageDialog":this.handleMessage(e);break;default:console.log("[MessageChannel] Unhandled transaction:",e)}}completeTransaction(e,t){let n=JSON.stringify({type:"TransactionComplete",content:JSON.stringify(t),id:e,cv:""});this._channel.send(n)}cancelTransaction(e,t){let n=JSON.stringify({type:"ReceiverCancel",content:JSON.stringify(t),id:e,cv:""});this._channel.send(n)}handleMessage(e){let t=JSON.parse(e.content);console.log(t),confirm(t.TitleText+`
+
+`+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{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}: ${t}`: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._player)}create(e){let t=document.createElement("video");t.srcObject=e,t.autoplay=!0,t.muted=!0,t.playsInline=!0,t.style.width="100%",t.style.height="100%",t.style.objectFit="contain",t.style.backgroundColor="black",t.style.touchAction="none",t.poster="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";let n=document.getElementById(this._player.getElementId());if(n===null)return;this._element=t,n.appendChild(this._element),getComputedStyle(n).position==="static"===!0&&(n.style.position="relative"),this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))}processVideoMetadata(e,t){this._element!==void 0&&(this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)),this._player._channels.input.queueMetadataFrame({serverDataKey:t.rtpTimestamp,firstFramePacketArrivalTimeMs:t.receiveTime,frameSubmittedTimeMs:t.receiveTime,frameDecodedTimeMs:t.expectedDisplayTime,frameRenderedTimeMs:t.expectedDisplayTime}))}getElement(){return this._element}toggleDebugOverlay(){this._overlay.toggleDebug()}destroy(){let t=document.getElementById(this._player.getElementId())?.querySelector("video");this._overlay!==void 0&&this._overlay.destroy(),t&&t.remove()}};var N=class{_player;_element;constructor(e){this._player=e}create(e){let t=document.createElement("audio");t.srcObject=e,t.autoplay=!0,t.muted=!1,this._element=t;let n=document.getElementById(this._player.getElementId());n!==null&&n.appendChild(this._element)}getElement(){return this._element}destroy(){let t=document.getElementById(this._player.getElementId())?.querySelector("audio");t&&t.remove()}};var U=class{_peerConnection=new RTCPeerConnection({});_channels={chat:new S(this),control:new w(this),input:new A(this),message:new M(this)};_elementId;_isDestoyed=!1;_iceHelper=new R(this);_sdpHelper=new I(this);_statsHelper=new P(this);_videoComponent;_audioComponent;constructor(e){console.log("xCloudPlayer constructing..."),this._elementId=e,this._peerConnection.addTransceiver("audio",{direction:"sendrecv"}),this._peerConnection.addTransceiver("video",{direction:"recvonly"}).setCodecPreferences(this._sdpHelper.getDefaultCodecPreferences()),this._peerConnection.ontrack=n=>{n.track.kind==="video"?(this._videoComponent=new L(this),this._videoComponent.create(n.streams[0])):n.track.kind==="audio"?(this._audioComponent=new N(this),this._audioComponent.create(n.streams[0])):console.log("[xPlayer] Detected an unknown stream type: ",n.track.kind)},console.log("xCloudPlayer constructed.")}init(){console.log("xCloudPlayer init called.")}getElementId(){return this._elementId}async createOffer(){let e=await this._peerConnection.createOffer({offerToReceiveAudio:!0,offerToReceiveVideo:!0}),t=this._sdpHelper.setLocalSDP(e);return this._peerConnection.setLocalDescription(t),t}async setRemoteSDP(e){let t=this._sdpHelper.setRemoteSDP(e);return await this._peerConnection.setRemoteDescription({type:"answer",sdp:t})}async getICECandidates(){return this._iceHelper.getCandidates()}async setRemoteIceCandidates(e){this._iceHelper.setRemoteCandidates(e)}getVideoElement(){if(this._videoComponent)return this._videoComponent.getElement()}getAudioElement(){if(this._audioComponent)return this._audioComponent.getElement()}toggleDebugOverlay(){this._videoComponent&&this._videoComponent.toggleDebugOverlay()}getStats(){return this._statsHelper}destroy(){if(this._isDestoyed===!1){this._peerConnection.close(),this._peerConnection.onconnectionstatechange&&this._peerConnection.onconnectionstatechange(new Event("connectionstatechanged"));for(let e in this._channels)this._channels[e].destroy();this._videoComponent&&this._videoComponent.destroy(),this._audioComponent&&this._audioComponent.destroy(),this._isDestoyed=!0}else console.log("Cannot destroy because the player is already destroyed.")}};function X(r){return r.brightness!==0||r.contrast!==0||Math.abs(r.gamma-1)>.001||r.saturation!==100||r.sharpen>0||r.deband>0||r.fxaa||r.toon||r.crt||r.ntsc||r.maxFps>0}var J=`#version 300 es
+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",pointerEvents:"none",display:"none",whiteSpace:"pre",textAlign:"right"}),document.body.appendChild(this.element)}element;timer;level=0;vertical=!1;left=!1;server="";last={bytes:0,time:0,decode:0,frames:0,lost:0,received:0};show(e,t,n,i=!1){this.level=e,this.vertical=t,this.server=n,this.left=i,Object.assign(this.element.style,i?{left:"8px",right:"",textAlign:"left"}:{left:"",right:"8px",textAlign:"right"}),this.element.style.display=e>0?"block":"none",e>0&&this.timer===void 0&&(this.timer=setInterval(()=>this.update(),1e3),this.update()),e===0&&this.timer!==void 0&&(clearInterval(this.timer),this.timer=void 0)}async update(){let e=await this.pc.getStats(),t,n,i=new Map;if(e.forEach(c=>{c.type==="inbound-rtp"&&c.kind==="video"?t=c:c.type==="candidate-pair"&&c.state==="succeeded"&&c.nominated?n=c:c.type==="codec"&&i.set(c.id,c)}),t===void 0)return;let a=this.last.time>0?(t.timestamp-this.last.time)/1e3:0,s=a>0?(t.bytesReceived-this.last.bytes)*8/a/1e6:0,o=(t.framesDecoded??0)-this.last.frames,d=o>0?((t.totalDecodeTime??0)-this.last.decode)*1e3/o:0,u=(t.packetsLost??0)-this.last.lost,h=(t.packetsReceived??0)-this.last.received,g=u+h>0?u*100/(u+h):0;this.last={bytes:t.bytesReceived,time:t.timestamp,decode:t.totalDecodeTime??0,frames:t.framesDecoded??0,lost:t.packetsLost??0,received:t.packetsReceived??0};let p=["web",`${Math.round(t.framesPerSecond??0)} fps`];if(this.level>=2&&(n?.currentRoundTripTime!==void 0&&p.push(`ping ${Math.round(n.currentRoundTripTime*1e3)} ms`),p.push(`${t.frameWidth??"?"}x${t.frameHeight??"?"}`)),this.level>=3&&p.push(`${s.toFixed(1)} Mb/s`,`decode ${d.toFixed(1)} ms`,`loss ${g.toFixed(1)}%`),this.level>=4){let c=i.get(t.codecId);c&&p.push(String(c.mimeType??"").replace("video/","")),p.push(`jitter ${Math.round((t.jitter??0)*1e3)} ms`,`dropped ${t.framesDropped??0}`),this.server&&p.push(this.server)}this.element.textContent=p.join(this.vertical?`
+`:" \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))});})();
diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt
index 33585818b..c2bfc8643 100644
--- a/app/src/main/cpp/waylandcomp/CMakeLists.txt
+++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt
@@ -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
)
diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c
index 95a8bf83a..0718623cf 100644
--- a/app/src/main/cpp/waylandcomp/src/compositor.c
+++ b/app/src/main/cpp/waylandcomp/src/compositor.c
@@ -16,6 +16,7 @@
#include
#include
#include
+#include
#include
#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;
}
}
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
index 94bb581a0..9de2824d7 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
+++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
@@ -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. */
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
new file mode 100644
index 000000000..0f8e1f61b
--- /dev/null
+++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
@@ -0,0 +1,170 @@
+#include "gpu_clock_pin.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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;
+}
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h
new file mode 100644
index 000000000..5fe17d102
--- /dev/null
+++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h
@@ -0,0 +1,5 @@
+#pragma once
+
+#include
+
+bool gpu_clock_pin_set(bool on);
diff --git a/app/src/main/cpp/waylandcomp/src/vk_loader.h b/app/src/main/cpp/waylandcomp/src/vk_loader.h
index e10950d0d..54cb88881 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_loader.h
+++ b/app/src/main/cpp/waylandcomp/src/vk_loader.h
@@ -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) \
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c
index 96d0b415d..c917afbba 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.c
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.c
@@ -19,6 +19,7 @@
#include
#include
#include
+#include
#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;
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h
index 48d32ab4d..7271f468b 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.h
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.h
@@ -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);
diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
index 6a407088b..e6bb03027 100644
--- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
+++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#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();
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index 3fc438377..324941bd4 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -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. */
diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt
index 7ccbba7f5..344aa7452 100644
--- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt
+++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt
@@ -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)
diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
similarity index 92%
rename from app/src/main/java/com/droiddeck/launcher/MainActivity.kt
rename to app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
index abc1eee8f..18244ae66 100644
--- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
@@ -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(null)
private var flood by mutableStateOf(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(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(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(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(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(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(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 = 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, 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),
diff --git a/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt b/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt
index 54526834b..71304e883 100644
--- a/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt
@@ -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)
diff --git a/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt b/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt
index 9183df76e..89c33523a 100644
--- a/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt
+++ b/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt
@@ -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> = 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
diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt
index 5bd651cf5..1d870a6bc 100644
--- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt
@@ -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>(emptyList())
var protonBusyId by mutableStateOf(null)
var protonStage by mutableStateOf(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)
+ }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 6de07a00d..5d8ad8e39 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -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? = null
+ private var screenKeyDir: Int? = null
+ private var screenKeyTextFocus = false
+ private var screenKeyEdit by mutableStateOf(null)
+ private var screenLayoutsOpen by mutableStateOf(false)
+ private var screenKeysShown by mutableStateOf(true)
+ private var screenLayoutNames by mutableStateOf(listOf())
+ private var screenKeys: ScreenKeys? = null
private var recognizedGesture by mutableStateOf(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()
private lateinit var scrollTouch: ScrollTouchGestures
@@ -270,6 +283,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private val resumeKeysDown = mutableSetOf>()
+ private val drawerShortcutKeysDown = mutableSetOf>()
private var drawerOpen by mutableStateOf(false)
private var quitFrom by mutableStateOf(null)
@@ -280,6 +294,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var drawerDefaultProton by mutableStateOf(null)
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
private var drawerGameProfileId by mutableStateOf(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()
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(null)
+ private var pausedFrameWanted = false
private var trackingRecovery by mutableStateOf(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 =
+ 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"
diff --git a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt
index 8459bc7d9..5eb9ff450 100644
--- a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt
+++ b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt
@@ -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 -> {
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt
index 5f7f09262..0842f6b5a 100644
--- a/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt
@@ -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(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 = 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
}
diff --git a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java
index 15ffd7bdb..f8f1b2a3e 100644
--- a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java
+++ b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java
@@ -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 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 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
diff --git a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java
index 849455b7a..3b73b6a3c 100644
--- a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java
+++ b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java
@@ -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();
diff --git a/app/src/main/java/com/droiddeck/launcher/core/BackgroundAppLimit.kt b/app/src/main/java/com/droiddeck/launcher/core/BackgroundAppLimit.kt
new file mode 100644
index 000000000..02447f360
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/core/BackgroundAppLimit.kt
@@ -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}") }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt b/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt
deleted file mode 100644
index adaa106a0..000000000
--- a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt
+++ /dev/null
@@ -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): 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()),
- )
- }
-}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
index 903851a5a..9d6226166 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
@@ -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))
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
index 870e8ed42..b11e3113a 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
@@ -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 ""
+ }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
index 183076188..98e41ab6f 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
+++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
@@ -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);
diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt
index 3ad45c1fa..bcf3a0448 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt
@@ -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),
diff --git a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt
index 4a8b12934..075719246 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt
@@ -8,7 +8,11 @@ object HostProcess {
private const val TAG = "HostProcess"
@JvmStatic
- fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?): Int {
+ fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?): Int =
+ start(command, env, dir, onExit, onLine, null)
+
+ @JvmStatic
+ fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?, 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)")
diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
index 491101dba..20073c4ca 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
@@ -76,7 +76,7 @@ object LogRedactor {
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
private val ACCOUNT_KV = Regex(
- "(?i)(? 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)
}
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt
index f8806c7c0..c924f8532 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt
@@ -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)
diff --git a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt
index 51c3b2494..3befaac38 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt
@@ -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()
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt
index 27cb9dbce..1425a78e7 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt
@@ -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() })
}
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
index 0843057f7..8d60241c8 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
@@ -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()) }
var selectedEntry by remember { mutableStateOf(null) }
var showMenuFor by remember { mutableStateOf(null) }
@@ -151,8 +166,10 @@ fun FileManagerScreen(
var pendingConflict by remember { mutableStateOf(null) }
var conflictChoice by remember { mutableStateOf(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
- var operationJob by remember { mutableStateOf(null) }
- var cancelledJob by remember { mutableStateOf(null) }
+ var operationJob by remember { mutableStateOf(null) }
+ var cancelledJob by remember { mutableStateOf(null) }
+ var pendingNavigation by remember { mutableStateOf(null) }
+ var cancellingNavigation by remember { mutableStateOf(false) }
var pendingBulkDelete by remember { mutableStateOf>(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(null) }
+ var listingJob by remember { mutableStateOf(null) }
+ val history = remember { mutableStateListOf>() }
+ 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 {
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
index 29df5ff6d..05d467ef5 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
@@ -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)
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt
index 6fd89fa99..498ec504e 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt
@@ -50,6 +50,7 @@ class FilePickerActivity : ComponentActivity() {
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
finish()
},
+ onExit = { finish() },
)
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt
index 99e940157..273917b00 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt
@@ -29,6 +29,50 @@ object StorageRoots {
val seeds = LinkedHashSet()
}
+ 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 {
+ val out = ArrayList()
+ 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 =
+ parseMountPoints(mounts).filter(::isExternalMount).map { it.target }
+
+ fun externalMounts(): List =
+ runCatching { parseExternalMounts(File("/proc/mounts").readText()) }.getOrDefault(emptyList())
+
fun list(context: Context): List {
val volumes = LinkedHashMap()
@@ -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
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt
index 62d04d247..28eb1bf72 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt
@@ -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 {
val own = local(game)
val cache = cacheDir(context, game)
+ val store = storeArt(context, game)
fun cached(name: String): Pair? = 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? = 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): Boolean {
+ internal class Sources(
+ val storeLogo: (AddedGames.Game) -> Boolean,
+ val steamSearch: (String) -> Int?,
+ val steam: (String, File, Set) -> Boolean,
+ val sgdbGame: (String, Int?, String) -> Int?,
+ val sgdb: (String, Int, File, Set) -> 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 {
+ 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): Boolean =
+ fetchMissing(context, games, SteamGridDb.key(context), network)
+
+ internal fun fetchMissing(context: Context, games: List, 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.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
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
index 5de835f6e..aa1c6eef4 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
@@ -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 = 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, 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()
val added = ArrayList()
- 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({ 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") }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt
index fc3274427..650c717d8 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt
@@ -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) },
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt
index ab997f027..388448e18 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt
@@ -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
}
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt
index 80cdd93c1..45b245269 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt
@@ -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 {
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
index 1242d9add..9fb776447 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
@@ -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 {
+ fun steamGames(
+ context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context),
+ withShared: Boolean = true,
+ ): List {
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 {
+ 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> {
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 = 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